diff --git a/.env.template b/.env.template index 36395fe933..b31d8adc2c 100644 --- a/.env.template +++ b/.env.template @@ -65,6 +65,12 @@ TEMPLATE_BUCKET_NAME= # Hash seed used for generating sandbox access tokens, not needed if you are not using them SANDBOX_ACCESS_TOKEN_HASH_SEED=abcdefghijklmnopqrstuvwxyz +# If you have a compatible vault instance running (pkg/vault) and want to use secrets egress proxy +VAULT_ADDR= +VAULT_APPROLE_ROLE_ID= +VAULT_APPROLE_SECRET_ID= +VAULT_TLS_CA= + # Integration tests variables (only for running integration tests locally) # your domain name, e.g. https://api.great-innovations.dev TESTS_API_SERVER_URL= diff --git a/.github/actions/start-services/action.yml b/.github/actions/start-services/action.yml index 4b86e072d4..18f78dbb5c 100644 --- a/.github/actions/start-services/action.yml +++ b/.github/actions/start-services/action.yml @@ -75,6 +75,10 @@ runs: make -C packages/clickhouse migrate-without-build shell: bash + - name: Run Vault + run: bash .github/actions/start-services/start-vault.sh + shell: bash + - name: Start Services env: ENVD_TIMEOUT: "60s" diff --git a/.github/actions/start-services/start-vault.sh b/.github/actions/start-services/start-vault.sh new file mode 100755 index 0000000000..ea3daf55c4 --- /dev/null +++ b/.github/actions/start-services/start-vault.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -e + +echo "Starting Vault in dev mode..." +docker run -d --name vault \ + --cap-add=IPC_LOCK \ + -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' \ + -e 'VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200' \ + -p 8200:8200 \ + hashicorp/vault:1.20.3 + +echo "Waiting for Vault to be ready..." +for i in {1..30}; do + if curl -s http://localhost:8200/v1/sys/health | grep -q "initialized"; then + echo "Vault is ready!" + break + fi + echo "Waiting for Vault... ($i/30)" + sleep 1 +done + +# Configure Vault +export VAULT_ADDR='http://localhost:8200' +export VAULT_TOKEN='myroot' + +echo "Enabling AppRole auth..." +docker exec -e VAULT_ADDR=$VAULT_ADDR -e VAULT_TOKEN=$VAULT_TOKEN vault \ + vault auth enable approle || true + +echo "Creating Vault policy..." +cat > /tmp/vault-policy.hcl <<'POLICY' +path "secret/data/*" { + capabilities = ["create", "read", "update", "delete"] +} +path "secret/metadata/*" { + capabilities = ["create", "read", "update", "delete"] +} +POLICY + +docker cp /tmp/vault-policy.hcl vault:/tmp/vault-policy.hcl +docker exec -e VAULT_ADDR=$VAULT_ADDR -e VAULT_TOKEN=$VAULT_TOKEN vault \ + vault policy write test-policy /tmp/vault-policy.hcl + +echo "Creating AppRole..." +docker exec -e VAULT_ADDR=$VAULT_ADDR -e VAULT_TOKEN=$VAULT_TOKEN vault \ + vault write auth/approle/role/test-role \ + token_policies="test-policy" \ + token_ttl=1h \ + token_max_ttl=4h + +echo "Getting role-id and secret-id..." +ROLE_ID=$(docker exec -e VAULT_ADDR=$VAULT_ADDR -e VAULT_TOKEN=$VAULT_TOKEN vault \ + vault read -field=role_id auth/approle/role/test-role/role-id) +SECRET_ID=$(docker exec -e VAULT_ADDR=$VAULT_ADDR -e VAULT_TOKEN=$VAULT_TOKEN vault \ + vault write -field=secret_id -f auth/approle/role/test-role/secret-id) + +echo "Exporting Vault configuration..." +if [ -n "$GITHUB_ENV" ]; then + echo "VAULT_ADDR=http://localhost:8200" >> $GITHUB_ENV + echo "VAULT_APPROLE_ROLE_ID=${ROLE_ID}" >> $GITHUB_ENV + echo "VAULT_APPROLE_SECRET_ID=${SECRET_ID}" >> $GITHUB_ENV +else + echo "VAULT_ADDR=http://localhost:8200" + echo "VAULT_APPROLE_ROLE_ID=${ROLE_ID}" + echo "VAULT_APPROLE_SECRET_ID=${SECRET_ID}" +fi + +echo "Vault setup complete!" + diff --git a/iac/provider-gcp/main.tf b/iac/provider-gcp/main.tf index a54091e6dc..58ebecf29b 100644 --- a/iac/provider-gcp/main.tf +++ b/iac/provider-gcp/main.tf @@ -190,6 +190,8 @@ module "nomad" { api_admin_token = random_password.api_admin_secret.result redis_url_secret_version = google_secret_manager_secret_version.redis_url sandbox_access_token_hash_seed = random_password.sandbox_access_token_hash_seed.result + vault_api_approle_secret_name = "${var.prefix}vault-api-approle" + vault_tls_ca_secret_name = "${var.prefix}vault-tls-ca" # Click Proxy client_proxy_count = var.client_proxy_count diff --git a/iac/provider-gcp/nomad/jobs/api.hcl b/iac/provider-gcp/nomad/jobs/api.hcl index b81bdeb0e2..7e0d3dc5f8 100644 --- a/iac/provider-gcp/nomad/jobs/api.hcl +++ b/iac/provider-gcp/nomad/jobs/api.hcl @@ -96,6 +96,10 @@ job "api" { REDIS_CLUSTER_URL = "${redis_cluster_url}" DNS_PORT = "${dns_port_number}" SANDBOX_ACCESS_TOKEN_HASH_SEED = "${sandbox_access_token_hash_seed}" + VAULT_ADDR = "${vault_addr}" + VAULT_APPROLE_ROLE_ID = "${vault_api_approle_creds != "" ? jsondecode(vault_api_approle_creds).role_id : ""}" + VAULT_APPROLE_SECRET_ID = "${vault_api_approle_creds != "" ? jsondecode(vault_api_approle_creds).secret_id : ""}" + VAULT_TLS_CA = ${jsonencode(vault_tls_ca)} LOCAL_CLUSTER_ENDPOINT = "${local_cluster_endpoint}" LOCAL_CLUSTER_TOKEN = "${local_cluster_token}" diff --git a/iac/provider-gcp/nomad/main.tf b/iac/provider-gcp/nomad/main.tf index eb93b32d23..b0b66ce3e1 100644 --- a/iac/provider-gcp/nomad/main.tf +++ b/iac/provider-gcp/nomad/main.tf @@ -47,6 +47,14 @@ data "google_secret_manager_secret_version" "redis_url" { secret = var.redis_url_secret_version.secret } +data "google_secret_manager_secret_version" "vault_api_approle" { + secret = var.vault_api_approle_secret_name +} + +data "google_secret_manager_secret_version" "vault_tls_ca" { + secret = var.vault_tls_ca_secret_name +} + data "docker_registry_image" "api_image" { name = "${var.gcp_region}-docker.pkg.dev/${var.gcp_project_id}/${var.orchestration_repository_name}/api:latest" @@ -101,6 +109,9 @@ resource "nomad_job" "api" { redis_cluster_url = data.google_secret_manager_secret_version.redis_url.secret_data != "redis.service.consul" ? "${data.google_secret_manager_secret_version.redis_url.secret_data}:${var.redis_port.port}" : "" dns_port_number = var.api_dns_port_number clickhouse_connection_string = local.clickhouse_connection_string + vault_addr = "https://vault-leader.service.consul:8200" + vault_api_approle_creds = data.google_secret_manager_secret_version.vault_api_approle.secret_data + vault_tls_ca = data.google_secret_manager_secret_version.vault_tls_ca.secret_data sandbox_access_token_hash_seed = var.sandbox_access_token_hash_seed db_migrator_docker_image = docker_image.db_migrator_image.repo_digest launch_darkly_api_key = trimspace(data.google_secret_manager_secret_version.launch_darkly_api_key.secret_data) diff --git a/iac/provider-gcp/nomad/variables.tf b/iac/provider-gcp/nomad/variables.tf index d0e0a7ad4c..6ea313adcc 100644 --- a/iac/provider-gcp/nomad/variables.tf +++ b/iac/provider-gcp/nomad/variables.tf @@ -368,3 +368,13 @@ variable "filestore_cache_cleanup_files_per_loop" { variable "dockerhub_remote_repository_url" { type = string } + +variable "vault_api_approle_secret_name" { + type = string + description = "The name of the Google Secret Manager secret containing Vault API AppRole credentials" +} + +variable "vault_tls_ca_secret_name" { + type = string + description = "The name of the Google Secret Manager secret containing Vault TLS CA certificate" +} diff --git a/packages/api/go.mod b/packages/api/go.mod index 18621dd7a1..d93a9f4831 100644 --- a/packages/api/go.mod +++ b/packages/api/go.mod @@ -167,12 +167,15 @@ require ( github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-msgpack v1.1.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl/v2 v2.19.1 // indirect github.com/hashicorp/memberlist v0.5.0 // indirect github.com/hashicorp/serf v0.10.1 // indirect + github.com/hashicorp/vault-client-go v0.4.3 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -232,6 +235,7 @@ require ( github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97 // indirect github.com/prometheus/procfs v0.16.0 // indirect github.com/prometheus/prometheus v1.8.2-0.20200727090838-6f296594a852 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sercand/kuberesolver/v5 v5.1.1 // indirect diff --git a/packages/api/go.sum b/packages/api/go.sum index 09ddb9856b..7ca7a48413 100644 --- a/packages/api/go.sum +++ b/packages/api/go.sum @@ -514,6 +514,8 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= @@ -540,6 +542,8 @@ github.com/hashicorp/nomad/api v0.0.0-20231208134655-099ee06a607c h1:7QPAGE7GJfS github.com/hashicorp/nomad/api v0.0.0-20231208134655-099ee06a607c/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= github.com/hetznercloud/hcloud-go/v2 v2.0.0 h1:Sg1DJ+MAKvbYAqaBaq9tPbwXBS2ckPIaMtVdUjKu+4g= github.com/hetznercloud/hcloud-go/v2 v2.0.0/go.mod h1:4iUG2NG8b61IAwNx6UsMWQ6IfIf/i1RsG0BbsKAyR5Q= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -853,6 +857,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.20 h1:a9hSJdJcd16e0HoMsnFvaHvxB3pxSD+SC7+CISp7xY0= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.20/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index 4c49c4bd22..46ad5be24e 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -77,6 +77,18 @@ type ServerInterface interface { // (POST /sandboxes/{sandboxID}/timeout) PostSandboxesSandboxIDTimeout(c *gin.Context, sandboxID SandboxID) + // (GET /secrets) + GetSecrets(c *gin.Context) + + // (POST /secrets) + PostSecrets(c *gin.Context) + + // (DELETE /secrets/{secretID}) + DeleteSecretsSecretID(c *gin.Context, secretID SecretID) + + // (PATCH /secrets/{secretID}) + PatchSecretsSecretID(c *gin.Context, secretID SecretID) + // (GET /teams) GetTeams(c *gin.Context) @@ -719,6 +731,104 @@ func (siw *ServerInterfaceWrapper) PostSandboxesSandboxIDTimeout(c *gin.Context) siw.Handler.PostSandboxesSandboxIDTimeout(c, sandboxID) } +// GetSecrets operation middleware +func (siw *ServerInterfaceWrapper) GetSecrets(c *gin.Context) { + + c.Set(ApiKeyAuthScopes, []string{}) + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetSecrets(c) +} + +// PostSecrets operation middleware +func (siw *ServerInterfaceWrapper) PostSecrets(c *gin.Context) { + + c.Set(ApiKeyAuthScopes, []string{}) + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostSecrets(c) +} + +// DeleteSecretsSecretID operation middleware +func (siw *ServerInterfaceWrapper) DeleteSecretsSecretID(c *gin.Context) { + + var err error + + // ------------- Path parameter "secretID" ------------- + var secretID SecretID + + err = runtime.BindStyledParameterWithOptions("simple", "secretID", c.Param("secretID"), &secretID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter secretID: %w", err), http.StatusBadRequest) + return + } + + c.Set(ApiKeyAuthScopes, []string{}) + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteSecretsSecretID(c, secretID) +} + +// PatchSecretsSecretID operation middleware +func (siw *ServerInterfaceWrapper) PatchSecretsSecretID(c *gin.Context) { + + var err error + + // ------------- Path parameter "secretID" ------------- + var secretID SecretID + + err = runtime.BindStyledParameterWithOptions("simple", "secretID", c.Param("secretID"), &secretID, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter secretID: %w", err), http.StatusBadRequest) + return + } + + c.Set(ApiKeyAuthScopes, []string{}) + + c.Set(Supabase1TokenAuthScopes, []string{}) + + c.Set(Supabase2TeamAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchSecretsSecretID(c, secretID) +} + // GetTeams operation middleware func (siw *ServerInterfaceWrapper) GetTeams(c *gin.Context) { @@ -1274,6 +1384,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/sandboxes/:sandboxID/refreshes", wrapper.PostSandboxesSandboxIDRefreshes) router.POST(options.BaseURL+"/sandboxes/:sandboxID/resume", wrapper.PostSandboxesSandboxIDResume) router.POST(options.BaseURL+"/sandboxes/:sandboxID/timeout", wrapper.PostSandboxesSandboxIDTimeout) + router.GET(options.BaseURL+"/secrets", wrapper.GetSecrets) + router.POST(options.BaseURL+"/secrets", wrapper.PostSecrets) + router.DELETE(options.BaseURL+"/secrets/:secretID", wrapper.DeleteSecretsSecretID) + router.PATCH(options.BaseURL+"/secrets/:secretID", wrapper.PatchSecretsSecretID) router.GET(options.BaseURL+"/teams", wrapper.GetTeams) router.GET(options.BaseURL+"/teams/:teamID/metrics", wrapper.GetTeamsTeamIDMetrics) router.GET(options.BaseURL+"/teams/:teamID/metrics/max", wrapper.GetTeamsTeamIDMetricsMax) diff --git a/packages/api/internal/api/spec.gen.go b/packages/api/internal/api/spec.gen.go index aade7a935b..2de2e9cc13 100644 --- a/packages/api/internal/api/spec.gen.go +++ b/packages/api/internal/api/spec.gen.go @@ -18,110 +18,115 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "9+9Wj1T3gW25dnN6898AAAD//yuTxj3LtgAA", + "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", + "TO1Ve0STkH5x/98AAAD//xBu6+esxQAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/packages/api/internal/api/types.gen.go b/packages/api/internal/api/types.gen.go index cc69e13be9..0a0ea94d09 100644 --- a/packages/api/internal/api/types.gen.go +++ b/packages/api/internal/api/types.gen.go @@ -135,6 +135,24 @@ type CreatedAccessToken struct { Token string `json:"token"` } +// CreatedSecret defines model for CreatedSecret. +type CreatedSecret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // CreatedAt When the secret was created + CreatedAt time.Time `json:"createdAt"` + + // Description Description of the secret + Description string `json:"description"` + + // Id Identifier of the secret + Id openapi_types.UUID `json:"id"` + + // Label Label of the secret + Label string `json:"label"` +} + // CreatedTeamAPIKey defines model for CreatedTeamAPIKey. type CreatedTeamAPIKey struct { // CreatedAt Timestamp of API key creation @@ -321,6 +339,21 @@ type NewSandbox struct { Timeout *int32 `json:"timeout,omitempty"` } +// NewSecret defines model for NewSecret. +type NewSecret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // Description Description of the secret + Description string `json:"description"` + + // Label Label of the secret + Label string `json:"label"` + + // Value Value of the secret + Value string `json:"value"` +} + // NewTeamAPIKey defines model for NewTeamAPIKey. type NewTeamAPIKey struct { // Name Name of the API key @@ -593,6 +626,24 @@ type SandboxesWithMetrics struct { Sandboxes map[string]SandboxMetric `json:"sandboxes"` } +// Secret defines model for Secret. +type Secret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // CreatedAt When the secret was created + CreatedAt time.Time `json:"createdAt"` + + // Description Description of the secret + Description *string `json:"description,omitempty"` + + // Id Identifier of the secret + Id openapi_types.UUID `json:"id"` + + // Label Label of the secret + Label string `json:"label"` +} + // Team defines model for Team. type Team struct { // ApiKey API key for the team @@ -806,6 +857,15 @@ type TemplateUpdateRequest struct { Public *bool `json:"public,omitempty"` } +// UpdateSecret defines model for UpdateSecret. +type UpdateSecret struct { + // Description New description for the secret + Description string `json:"description"` + + // Label New label for the secret + Label string `json:"label"` +} + // UpdateTeamAPIKey defines model for UpdateTeamAPIKey. type UpdateTeamAPIKey struct { // Name New name for the API key @@ -827,6 +887,9 @@ type NodeID = string // SandboxID defines model for sandboxID. type SandboxID = string +// SecretID defines model for secretID. +type SecretID = string + // TeamID defines model for teamID. type TeamID = string @@ -968,6 +1031,12 @@ type PostSandboxesSandboxIDResumeJSONRequestBody = ResumedSandbox // PostSandboxesSandboxIDTimeoutJSONRequestBody defines body for PostSandboxesSandboxIDTimeout for application/json ContentType. type PostSandboxesSandboxIDTimeoutJSONRequestBody PostSandboxesSandboxIDTimeoutJSONBody +// PostSecretsJSONRequestBody defines body for PostSecrets for application/json ContentType. +type PostSecretsJSONRequestBody = NewSecret + +// PatchSecretsSecretIDJSONRequestBody defines body for PatchSecretsSecretID for application/json ContentType. +type PatchSecretsSecretIDJSONRequestBody = UpdateSecret + // PostTemplatesJSONRequestBody defines body for PostTemplates for application/json ContentType. type PostTemplatesJSONRequestBody = TemplateBuildRequest diff --git a/packages/api/internal/cfg/model.go b/packages/api/internal/cfg/model.go index e1df80f3fe..75f1b0a345 100644 --- a/packages/api/internal/cfg/model.go +++ b/packages/api/internal/cfg/model.go @@ -33,6 +33,12 @@ type Config struct { SupabaseJWTSecrets []string `env:"SUPABASE_JWT_SECRETS"` TemplateManagerHost string `env:"TEMPLATE_MANAGER_HOST"` + + // Vault configuration + VaultAddr string `env:"VAULT_ADDR"` + VaultApproleRoleID string `env:"VAULT_APPROLE_ROLE_ID"` + VaultApproleSecretID string `env:"VAULT_APPROLE_SECRET_ID"` + VaultTLSCA string `env:"VAULT_TLS_CA"` } func Parse() (Config, error) { diff --git a/packages/api/internal/handlers/secrets.go b/packages/api/internal/handlers/secrets.go new file mode 100644 index 0000000000..bfab1b002b --- /dev/null +++ b/packages/api/internal/handlers/secrets.go @@ -0,0 +1,337 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/api/internal/api" + "github.com/e2b-dev/infra/packages/api/internal/secrets" + "github.com/e2b-dev/infra/packages/api/internal/utils" + featureflags "github.com/e2b-dev/infra/packages/shared/pkg/feature-flags" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +const ( + // MaxSecretValueBytes is 8KB to match typical HTTP header value limits and keep memory cache reasonable. + // Vault supports at least up to 512KB: https://developer.hashicorp.com/vault/docs/internals/limits + MaxSecretValueBytes = 8192 + // MaxSecretLabelChars limits label length for readability and reasonable storage + MaxSecretLabelChars = 256 + // MaxSecretDescriptionChars limits description length for readability and reasonable storage + MaxSecretDescriptionChars = 1024 + // MaxSecretAllowlistHosts is an arbitrary but reasonable limit + MaxSecretAllowlistHosts = 10 +) + +func (a *APIStore) GetSecrets(c *gin.Context) { + ctx := c.Request.Context() + teamID := a.GetTeamInfo(c).Team.ID + + if a.secretVault == nil { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets are disabled. Configure the secret vault to enable secrets.") + return + } + + secretsFeatureFlag, err := a.featureFlags.BoolFlag(ctx, featureflags.SecretsFeatureFlag) + if err != nil { + zap.L().Error("Failed to get Secrets feature flag", zap.Error(err)) + } + + if !secretsFeatureFlag { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets feature flag is disabled") + return + } + + secretsDB, err := a.sqlcDB.GetTeamSecrets(ctx, teamID) + if err != nil { + zap.L().Warn("error when getting team secrets", zap.Error(err), logger.WithTeamID(teamID.String())) + a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when getting team secrets") + + telemetry.ReportCriticalError(ctx, "error when getting team secrets", err) + + return + } + + secretsList := make([]api.Secret, len(secretsDB)) + for i, secret := range secretsDB { + secretsList[i] = api.Secret{ + Id: secret.ID, + Label: secret.Label, + Description: &secret.Description, + Allowlist: secret.Allowlist, + CreatedAt: secret.CreatedAt, + } + } + + zap.L().Debug("Fetched team secrets", + logger.WithTeamID(teamID.String()), + zap.Int("secrets_count", len(secretsList)), + ) + + c.JSON(http.StatusOK, secretsList) +} + +func (a *APIStore) PostSecrets(c *gin.Context) { + ctx := c.Request.Context() + teamID := a.GetTeamInfo(c).Team.ID + + if a.secretVault == nil { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets are disabled. Configure the secret vault to enable secrets.") + return + } + + secretsFeatureFlag, err := a.featureFlags.BoolFlag(ctx, featureflags.SecretsFeatureFlag) + if err != nil { + zap.L().Error("Failed to get Secrets feature flag", zap.Error(err)) + } + + if !secretsFeatureFlag { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets feature flag is disabled") + return + } + + body, err := utils.ParseBody[api.NewSecret](ctx, c) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when parsing request", err) + + return + } + + if body.Value == "" { + a.sendAPIStoreError(c, http.StatusBadRequest, "Secret value cannot be empty") + return + } + + if len(body.Value) > MaxSecretValueBytes { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Secret value cannot exceed %d bytes (got %d bytes)", MaxSecretValueBytes, len(body.Value))) + return + } + + if body.Label == "" { + a.sendAPIStoreError(c, http.StatusBadRequest, "Label cannot be empty") + return + } + + if len(body.Label) > MaxSecretLabelChars { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Label cannot exceed %d characters (got %d)", MaxSecretLabelChars, len(body.Label))) + return + } + + if len(body.Description) > MaxSecretDescriptionChars { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Description cannot exceed %d characters (got %d)", MaxSecretDescriptionChars, len(body.Description))) + return + } + + if len(body.Allowlist) > MaxSecretAllowlistHosts { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Too many hosts in allowlist (%d), only %d allowed", len(body.Allowlist), MaxSecretAllowlistHosts)) + return + } + + // default value, should be done by the SDK/Dashboard/CLI but just in case + if len(body.Allowlist) == 0 { + body.Allowlist = []string{"*"} + } + + for _, host := range body.Allowlist { + if err := validateHostname(host); err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, err.Error()) + return + } + } + + secret, err := secrets.CreateSecret(ctx, a.sqlcDB, a.secretVault, teamID, body.Value, body.Label, body.Description, body.Allowlist) + if err != nil { + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when creating team secret: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when creating team secret", err) + + return + } + + telemetry.ReportEvent(ctx, "Created secret") + + isWildcardAllowlist := len(secret.Allowlist) == 1 && secret.Allowlist[0] == "*" + zap.L().Debug("Created secret", + logger.WithTeamID(teamID.String()), + zap.String("secret_id", secret.ID.String()), + zap.Int("label_length", len(secret.Label)), + zap.Int("allowlist_size", len(secret.Allowlist)), + zap.Bool("wildcard_allowlist", isWildcardAllowlist), + zap.Int("value_bytes", len(body.Value)), + ) + + c.JSON(http.StatusCreated, api.CreatedSecret{ + Id: secret.ID, + Label: secret.Label, + Description: secret.Description, + + Allowlist: secret.Allowlist, + CreatedAt: secret.CreatedAt, + }) +} + +func (a *APIStore) PatchSecretsSecretID(c *gin.Context, secretID string) { + ctx := c.Request.Context() + teamID := a.GetTeamInfo(c).Team.ID + + if a.secretVault == nil { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets are disabled. Configure the secret vault to enable secrets.") + return + } + + secretsFeatureFlag, err := a.featureFlags.BoolFlag(ctx, featureflags.SecretsFeatureFlag) + if err != nil { + zap.L().Error("Failed to get Secrets feature flag", zap.Error(err)) + } + + if !secretsFeatureFlag { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets feature flag is disabled") + return + } + + secretIDParsed, err := uuid.Parse(secretID) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing secret ID: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when parsing secret ID", err) + return + } + + body, err := utils.ParseBody[api.UpdateSecret](ctx, c) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when parsing request", err) + + return + } + + if body.Label == "" { + a.sendAPIStoreError(c, http.StatusBadRequest, "Label cannot be empty") + return + } + + if len(body.Label) > MaxSecretLabelChars { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Label cannot exceed %d characters (got %d)", MaxSecretLabelChars, len(body.Label))) + return + } + + if len(body.Description) > MaxSecretDescriptionChars { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Description cannot exceed %d characters (got %d)", MaxSecretDescriptionChars, len(body.Description))) + return + } + + if err := secrets.UpdateSecret(ctx, a.sqlcDB, teamID, secretIDParsed, body.Label, body.Description); err != nil { + if errors.Is(err, secrets.ErrSecretNotFound) { + a.sendAPIStoreError(c, http.StatusNotFound, "Secret not found") + return + } + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when updating secret: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when updating secret", err) + return + } + + zap.L().Debug("Updated secret", + logger.WithTeamID(teamID.String()), + zap.String("secret_id", secretID), + zap.Int("label_length", len(body.Label)), + zap.Int("description_length", len(body.Description)), + ) + + c.Status(http.StatusOK) +} + +func (a *APIStore) DeleteSecretsSecretID(c *gin.Context, secretID string) { + ctx := c.Request.Context() + teamID := a.GetTeamInfo(c).Team.ID + + if a.secretVault == nil { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets are disabled. Configure the secret vault to enable secrets.") + return + } + + secretsFeatureFlag, err := a.featureFlags.BoolFlag(ctx, featureflags.SecretsFeatureFlag) + if err != nil { + zap.L().Error("Failed to get Secrets feature flag", zap.Error(err)) + } + + if !secretsFeatureFlag { + a.sendAPIStoreError(c, http.StatusForbidden, "Secrets feature flag is disabled") + return + } + + secretIDParsed, err := uuid.Parse(secretID) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing secret ID: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when parsing secret ID", err) + return + } + + if err := secrets.DeleteSecret(ctx, a.sqlcDB, a.secretVault, teamID, secretIDParsed); err != nil { + if errors.Is(err, secrets.ErrSecretNotFound) { + a.sendAPIStoreError(c, http.StatusNotFound, "Secret not found") + return + } + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when deleting secret: %s", err)) + + telemetry.ReportCriticalError(ctx, "error when deleting secret", err) + return + } + + zap.L().Debug("Deleted secret", + logger.WithTeamID(teamID.String()), + zap.String("secret_id", secretID), + ) + + c.Status(http.StatusNoContent) +} + +// ValidateHostname validates a hostname with wildcard support +// Allowed: example.com, *.example.com, something.*.example.com, *, *.* +// Not allowed: URLs with schemes, paths, or invalid characters +// See the test cases for more examples. +func validateHostname(hostname string) error { + // Most will be a wildcard anyway so we can skip the rest of the checks + if hostname == "*" { + return nil + } + + // Check for common URL indicators that make it invalid + if strings.Contains(hostname, "://") || + strings.Contains(hostname, "/") || + strings.HasPrefix(hostname, "http") { + return fmt.Errorf("invalid hostname pattern: %s, cannot contain schemes (https/http) or paths (/api/, /v2/)", hostname) + } + + // Check if its a valid go glob pattern + // match continues scanning to the end of the pattern even after a mismatch, so by matching "" we can check if the host is a valid pattern + // https://go-review.googlesource.com/c/go/+/264397 + if _, err := filepath.Match(hostname, ""); err != nil { + return fmt.Errorf("invalid hostname pattern: %w", err) + } + + // Regex pattern for hostname validation with wildcards + // - Each label can be alphanumeric with hyphens (not starting/ending with hyphen) + // - OR it can be a wildcard (*) + // - Labels are separated by dots + pattern := `^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?|\*)(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?|\.\*)*$` + + if matched, err := regexp.MatchString(pattern, hostname); err != nil || !matched { + return fmt.Errorf("invalid hostname pattern: %s", hostname) + } + + return nil +} diff --git a/packages/api/internal/handlers/secrets_test.go b/packages/api/internal/handlers/secrets_test.go new file mode 100644 index 0000000000..e0a37eb309 --- /dev/null +++ b/packages/api/internal/handlers/secrets_test.go @@ -0,0 +1,339 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateHostname(t *testing.T) { + t.Run("valid hostnames", func(t *testing.T) { + validCases := []string{ + "example.com", + "sub.example.com", + "sub-domain.example.com", + "example123.com", + "123-example.com", + "a.b.c.d.example.com", + "api.example.com", + "api-v2.example.com", + "1.2.3.4", // IP-like patterns are valid hostnames + "localhost", + "a.co", + } + + for _, hostname := range validCases { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + assert.NoError(t, err, "hostname %q should be valid", hostname) + }) + } + }) + + t.Run("valid wildcard patterns", func(t *testing.T) { + validWildcards := []string{ + "*", + "*.example.com", + "*.*.example.com", + "api.*.example.com", + "*.*", + "*.*.*.example.com", + } + + for _, hostname := range validWildcards { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + assert.NoError(t, err, "wildcard pattern %q should be valid", hostname) + }) + } + }) + + t.Run("mixed wildcard with other characters in same label is invalid", func(t *testing.T) { + invalidMixed := []string{ + "*-service.example.com", + "api-*.example.com", + "*api.example.com", + "api*.example.com", + } + + for _, hostname := range invalidMixed { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with mixed wildcard should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("glob patterns with question marks are invalid", func(t *testing.T) { + invalidGlobs := []string{ + "api-?.test.com", + "example?.com", + "?example.com", + } + + for _, hostname := range invalidGlobs { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with question mark should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("glob patterns with brackets are invalid", func(t *testing.T) { + invalidGlobs := []string{ + "service[1-3].example.com", + "host[abc].example.com", + "[invalid-pattern", + "host[1-3.example.com", // unclosed bracket + } + + for _, hostname := range invalidGlobs { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with brackets should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("URL schemes are invalid", func(t *testing.T) { + invalidSchemes := []string{ + "https://example.com", + "http://example.com", + "ftp://example.com", + "ws://example.com", + } + + for _, hostname := range invalidSchemes { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with scheme should be invalid", hostname) + assert.Contains(t, err.Error(), "cannot contain schemes") + }) + } + }) + + t.Run("URLs with paths are invalid", func(t *testing.T) { + invalidPaths := []string{ + "example.com/api", + "example.com/api/v1", + "api.example.com/endpoint", + } + + for _, hostname := range invalidPaths { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with path should be invalid", hostname) + assert.Contains(t, err.Error(), "cannot contain schemes") + }) + } + }) + + t.Run("ports are invalid", func(t *testing.T) { + invalidPorts := []string{ + "example.com:8080", + "localhost:3000", + "api.example.com:443", + } + + for _, hostname := range invalidPorts { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with port should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("invalid characters", func(t *testing.T) { + invalidChars := []string{ + "example!.com", + "example@.com", + "example#.com", + "example$.com", + "example%.com", + "example^.com", + "example&.com", + "example(.com", + "example).com", + "example+.com", + "example=.com", + "example_.com", // underscore is actually invalid in hostnames (valid in DNS but not in URLs) + "host\\example.com", + } + + for _, hostname := range invalidChars { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with special characters should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("whitespace is invalid", func(t *testing.T) { + invalidWhitespace := []string{ + "example .com", + " example.com", + "example.com ", + "example\t.com", + "example\n.com", + } + + for _, hostname := range invalidWhitespace { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with whitespace should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("labels cannot start with hyphen", func(t *testing.T) { + invalidHyphens := []string{ + "-example.com", + "sub.-example.com", + "api.-test.com", + } + + for _, hostname := range invalidHyphens { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with label starting with hyphen should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("labels cannot end with hyphen", func(t *testing.T) { + invalidHyphens := []string{ + "example-.com", + "sub.example-.com", + "api-test-.com", + } + + for _, hostname := range invalidHyphens { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with label ending with hyphen should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("multiple consecutive dots are invalid", func(t *testing.T) { + invalidDots := []string{ + "example..com", + "sub...example.com", + "api..test..com", + } + + for _, hostname := range invalidDots { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with consecutive dots should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("leading dot is invalid", func(t *testing.T) { + invalidDots := []string{ + ".example.com", + ".com", + ".api.test.com", + } + + for _, hostname := range invalidDots { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with leading dot should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("trailing dot is invalid", func(t *testing.T) { + invalidDots := []string{ + "example.com.", + "api.example.com.", + } + + for _, hostname := range invalidDots { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q with trailing dot should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("empty string is invalid", func(t *testing.T) { + err := validateHostname("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + + t.Run("only dots are invalid", func(t *testing.T) { + invalidDots := []string{ + ".", + "..", + "...", + } + + for _, hostname := range invalidDots { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + require.Error(t, err, "hostname %q should be invalid", hostname) + assert.Contains(t, err.Error(), "invalid hostname pattern") + }) + } + }) + + t.Run("single character labels are valid", func(t *testing.T) { + validSingle := []string{ + "a.b.c", + "x.y.z.example.com", + } + + for _, hostname := range validSingle { + t.Run(hostname, func(t *testing.T) { + err := validateHostname(hostname) + assert.NoError(t, err, "hostname %q with single character labels should be valid", hostname) + }) + } + }) + + t.Run("very long label is invalid", func(t *testing.T) { + // DNS labels have a maximum length of 63 characters + longLabel := "" + for range 64 { + longLabel += "a" + } + hostname := longLabel + ".example.com" + + err := validateHostname(hostname) + require.Error(t, err, "hostname with 64-character label should be invalid") + require.Contains(t, err.Error(), "invalid hostname pattern") + }) + + t.Run("label with exactly 63 characters is valid", func(t *testing.T) { + // DNS labels can be up to 63 characters + maxLabel := "" + for range 63 { + maxLabel += "a" + } + hostname := maxLabel + ".example.com" + + err := validateHostname(hostname) + assert.NoError(t, err, "hostname with 63-character label should be valid") + }) + + t.Run("wildcard only should return early", func(t *testing.T) { + err := validateHostname("*") + assert.NoError(t, err, "single wildcard should be valid") + }) +} diff --git a/packages/api/internal/handlers/store.go b/packages/api/internal/handlers/store.go index e33b79fe35..650c2d5ef0 100644 --- a/packages/api/internal/handlers/store.go +++ b/packages/api/internal/handlers/store.go @@ -33,6 +33,7 @@ import ( featureflags "github.com/e2b-dev/infra/packages/shared/pkg/feature-flags" "github.com/e2b-dev/infra/packages/shared/pkg/keys" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" + "github.com/e2b-dev/infra/packages/shared/pkg/vault" ) // minSupabaseJWTSecretLength is the minimum length of a secret used to verify the Supabase JWT. @@ -56,6 +57,7 @@ type APIStore struct { envdAccessTokenGenerator *sandbox.EnvdAccessTokenGenerator featureFlags *featureflags.Client clustersPool *edge.Pool + secretVault vault.VaultBackend } func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config) *APIStore { @@ -138,6 +140,23 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config) zap.L().Fatal("failed to create feature flags client", zap.Error(err)) } + var secretVault *vault.Client + if config.VaultAddr != "" { + secretVault, err = vault.NewClient(ctx, vault.ClientConfig{ + Address: config.VaultAddr, + RoleID: config.VaultApproleRoleID, + SecretID: config.VaultApproleSecretID, + CACert: config.VaultTLSCA, + Logger: zap.L(), + }) + if err != nil { + zap.L().Fatal("failed to create secret vault client", zap.Error(err)) + } + zap.L().Info("Secret vault client initialized") + } else { + zap.L().Info("Secret vault disabled (VAULT_ADDR not set)") + } + orch, err := orchestrator.New(ctx, config, tel, nomadClient, posthogClient, redisClient, dbClient, sqlcDB, clustersPool, featureFlags) if err != nil { zap.L().Fatal("Initializing Orchestrator client", zap.Error(err)) @@ -178,6 +197,7 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config) envdAccessTokenGenerator: accessTokenGenerator, clustersPool: clustersPool, featureFlags: featureFlags, + secretVault: secretVault, } // Wait till there's at least one, otherwise we can't create sandboxes yet @@ -224,6 +244,10 @@ func (a *APIStore) Close(ctx context.Context) error { errs = append(errs, fmt.Errorf("closing database client: %w", err)) } + if a.secretVault != nil { + a.secretVault.Close() + } + return errors.Join(errs...) } diff --git a/packages/api/internal/secrets/secrets.go b/packages/api/internal/secrets/secrets.go new file mode 100644 index 0000000000..21f457f1e6 --- /dev/null +++ b/packages/api/internal/secrets/secrets.go @@ -0,0 +1,129 @@ +package secrets + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "go.uber.org/zap" + + sqlcdb "github.com/e2b-dev/infra/packages/db/client" + "github.com/e2b-dev/infra/packages/db/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/vault" +) + +var ErrSecretNotFound = errors.New("secret not found") + +// CreateSecret creates a new secret in both the database and vault +func CreateSecret( + ctx context.Context, + db *sqlcdb.Client, + vaultClient vault.VaultBackend, + teamID uuid.UUID, + value string, + label string, + description string, + allowlist []string, +) (*queries.CreateSecretRow, error) { + secretID := uuid.New() + + // Store secret value in vault + vaultPath := getVaultPath(teamID, secretID) + if err := vaultClient.WriteSecret(ctx, vaultPath, value, map[string]any{ + "allowlist": allowlist, + }); err != nil { + return nil, fmt.Errorf("failed to store secret in vault: %w", err) + } + + // Store secret metadata in database + secret, err := db.CreateSecret(ctx, queries.CreateSecretParams{ + ID: secretID, + TeamID: teamID, + Label: label, + Description: description, + Allowlist: allowlist, + }) + if err != nil { + // Attempt to clean up vault entry if database insert fails + if deleteErr := vaultClient.DeleteSecret(ctx, vaultPath); deleteErr != nil { + zap.L().Error("failed to clean up vault secret after database error", + zap.Error(deleteErr), + zap.String("vault_path", vaultPath), + ) + } + return nil, fmt.Errorf("failed to store secret in database: %w", err) + } + + return &secret, nil +} + +// DeleteSecret deletes a secret from both the database and vault +func DeleteSecret( + ctx context.Context, + db *sqlcdb.Client, + vaultClient vault.VaultBackend, + teamID uuid.UUID, + secretID uuid.UUID, +) error { + // Delete from database first to ensure it's actually the team's secret + rowsAffected, err := db.DeleteSecret(ctx, queries.DeleteSecretParams{ + ID: secretID, + TeamID: teamID, + }) + if err != nil { + return fmt.Errorf("failed to delete secret from database: %w", err) + } + + // Check if the secret existed + if rowsAffected == 0 { + return ErrSecretNotFound + } + + // Delete from vault + vaultPath := getVaultPath(teamID, secretID) + if err := vaultClient.DeleteSecret(ctx, vaultPath); err != nil { + // Log but don't fail if vault deletion fails + // The database record is already gone, which is the source of truth + zap.L().Warn("failed to delete secret from vault", + zap.Error(err), + zap.String("vault_path", vaultPath), + zap.String("secret_id", secretID.String()), + ) + } + + return nil +} + +// UpdateSecret updates a secret's label and description in the database +func UpdateSecret( + ctx context.Context, + db *sqlcdb.Client, + teamID uuid.UUID, + secretID uuid.UUID, + label string, + description string, +) error { + // Update in database + rowsAffected, err := db.UpdateSecret(ctx, queries.UpdateSecretParams{ + ID: secretID, + TeamID: teamID, + Label: label, + Description: description, + }) + if err != nil { + return fmt.Errorf("failed to update secret in database: %w", err) + } + + // Check if the secret existed and belonged to the team + if rowsAffected == 0 { + return ErrSecretNotFound + } + + return nil +} + +// getVaultPath returns the vault path for a team's secret +func getVaultPath(teamID uuid.UUID, secretID uuid.UUID) string { + return fmt.Sprintf("teams/%s/secrets/%s", teamID.String(), secretID.String()) +} diff --git a/packages/api/main.go b/packages/api/main.go index fd70a117be..1fd9bf91fa 100644 --- a/packages/api/main.go +++ b/packages/api/main.go @@ -83,6 +83,8 @@ func NewGinServer(ctx context.Context, config cfg.Config, tel *telemetry.Client, "/sandboxes/:sandboxID", "/sandboxes/:sandboxID/pause", "/sandboxes/:sandboxID/resume", + "/secrets", + "/secrets/:secretID", ), gin.Recovery(), ) diff --git a/packages/db/migrations/20251006171957_add_secrets.sql b/packages/db/migrations/20251006171957_add_secrets.sql new file mode 100644 index 0000000000..8e5b09e340 --- /dev/null +++ b/packages/db/migrations/20251006171957_add_secrets.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- +goose StatementBegin + +-- Create "secrets" table +CREATE TABLE IF NOT EXISTS "public"."secrets" +( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamptz NULL, + "team_id" uuid NOT NULL, + "label" text NOT NULL DEFAULT 'Unlabelled Secret', + "description" text NOT NULL DEFAULT '', + "allowlist" text[] NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "secrets_teams_team_secrets" FOREIGN KEY ("team_id") REFERENCES "public"."teams" ("id") ON UPDATE NO ACTION ON DELETE CASCADE +); + +-- Create index for efficient team queries +CREATE INDEX IF NOT EXISTS idx_teams_secrets ON public.secrets (team_id); + +-- Enable RLS +ALTER TABLE "public"."secrets" ENABLE ROW LEVEL SECURITY; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Drop table +DROP TABLE IF EXISTS "public"."secrets"; + +-- +goose StatementEnd \ No newline at end of file diff --git a/packages/db/queries/create_secret.sql b/packages/db/queries/create_secret.sql new file mode 100644 index 0000000000..2deb7cb284 --- /dev/null +++ b/packages/db/queries/create_secret.sql @@ -0,0 +1,16 @@ +-- name: CreateSecret :one +INSERT INTO "public"."secrets"( + id, + team_id, + label, + description, + allowlist +) +VALUES ( + @id, + @team_id, + @label, + @description, + @allowlist +) RETURNING id, team_id, label, description, allowlist, created_at; + diff --git a/packages/db/queries/create_secret.sql.go b/packages/db/queries/create_secret.sql.go new file mode 100644 index 0000000000..1cd390eb68 --- /dev/null +++ b/packages/db/queries/create_secret.sql.go @@ -0,0 +1,67 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: create_secret.sql + +package queries + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createSecret = `-- name: CreateSecret :one +INSERT INTO "public"."secrets"( + id, + team_id, + label, + description, + allowlist +) +VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING id, team_id, label, description, allowlist, created_at +` + +type CreateSecretParams struct { + ID uuid.UUID + TeamID uuid.UUID + Label string + Description string + Allowlist []string +} + +type CreateSecretRow struct { + ID uuid.UUID + TeamID uuid.UUID + Label string + Description string + Allowlist []string + CreatedAt time.Time +} + +func (q *Queries) CreateSecret(ctx context.Context, arg CreateSecretParams) (CreateSecretRow, error) { + row := q.db.QueryRow(ctx, createSecret, + arg.ID, + arg.TeamID, + arg.Label, + arg.Description, + arg.Allowlist, + ) + var i CreateSecretRow + err := row.Scan( + &i.ID, + &i.TeamID, + &i.Label, + &i.Description, + &i.Allowlist, + &i.CreatedAt, + ) + return i, err +} diff --git a/packages/db/queries/delete_secret.sql b/packages/db/queries/delete_secret.sql new file mode 100644 index 0000000000..fc3eea1495 --- /dev/null +++ b/packages/db/queries/delete_secret.sql @@ -0,0 +1,5 @@ +-- name: DeleteSecret :execrows +DELETE FROM "public"."secrets" +WHERE id = @id +AND team_id = @team_id; + diff --git a/packages/db/queries/delete_secret.sql.go b/packages/db/queries/delete_secret.sql.go new file mode 100644 index 0000000000..25ee94ec67 --- /dev/null +++ b/packages/db/queries/delete_secret.sql.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: delete_secret.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const deleteSecret = `-- name: DeleteSecret :execrows +DELETE FROM "public"."secrets" +WHERE id = $1 +AND team_id = $2 +` + +type DeleteSecretParams struct { + ID uuid.UUID + TeamID uuid.UUID +} + +func (q *Queries) DeleteSecret(ctx context.Context, arg DeleteSecretParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteSecret, arg.ID, arg.TeamID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/packages/db/queries/get_secrets.sql b/packages/db/queries/get_secrets.sql new file mode 100644 index 0000000000..8922afbfa5 --- /dev/null +++ b/packages/db/queries/get_secrets.sql @@ -0,0 +1,11 @@ +-- name: GetTeamSecrets :many +SELECT + id, + label, + description, + allowlist, + created_at +FROM "public"."secrets" +WHERE team_id = @team_id +ORDER BY created_at DESC; + diff --git a/packages/db/queries/get_secrets.sql.go b/packages/db/queries/get_secrets.sql.go new file mode 100644 index 0000000000..627227eb31 --- /dev/null +++ b/packages/db/queries/get_secrets.sql.go @@ -0,0 +1,59 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_secrets.sql + +package queries + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const getTeamSecrets = `-- name: GetTeamSecrets :many +SELECT + id, + label, + description, + allowlist, + created_at +FROM "public"."secrets" +WHERE team_id = $1 +ORDER BY created_at DESC +` + +type GetTeamSecretsRow struct { + ID uuid.UUID + Label string + Description string + Allowlist []string + CreatedAt time.Time +} + +func (q *Queries) GetTeamSecrets(ctx context.Context, teamID uuid.UUID) ([]GetTeamSecretsRow, error) { + rows, err := q.db.Query(ctx, getTeamSecrets, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamSecretsRow + for rows.Next() { + var i GetTeamSecretsRow + if err := rows.Scan( + &i.ID, + &i.Label, + &i.Description, + &i.Allowlist, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 018595779e..18fd2de0b9 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -80,6 +80,16 @@ type EnvBuild struct { Reason types.BuildReason } +type Secret struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt *time.Time + TeamID uuid.UUID + Label string + Description string + Allowlist []string +} + type Snapshot struct { CreatedAt pgtype.Timestamptz EnvID string diff --git a/packages/db/queries/update_secret.sql b/packages/db/queries/update_secret.sql new file mode 100644 index 0000000000..98346f27d9 --- /dev/null +++ b/packages/db/queries/update_secret.sql @@ -0,0 +1,9 @@ +-- name: UpdateSecret :execrows +UPDATE "public"."secrets" +SET + label = @label, + description = @description, + updated_at = CURRENT_TIMESTAMP +WHERE id = @id +AND team_id = @team_id; + diff --git a/packages/db/queries/update_secret.sql.go b/packages/db/queries/update_secret.sql.go new file mode 100644 index 0000000000..7935f8da9b --- /dev/null +++ b/packages/db/queries/update_secret.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: update_secret.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const updateSecret = `-- name: UpdateSecret :execrows +UPDATE "public"."secrets" +SET + label = $1, + description = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $3 +AND team_id = $4 +` + +type UpdateSecretParams struct { + Label string + Description string + ID uuid.UUID + TeamID uuid.UUID +} + +func (q *Queries) UpdateSecret(ctx context.Context, arg UpdateSecretParams) (int64, error) { + result, err := q.db.Exec(ctx, updateSecret, + arg.Label, + arg.Description, + arg.ID, + arg.TeamID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/packages/shared/go.mod b/packages/shared/go.mod index 041ae74a5c..40face5dde 100644 --- a/packages/shared/go.mod +++ b/packages/shared/go.mod @@ -45,12 +45,14 @@ require ( github.com/grafana/loki v0.0.0-20250609195516-7b805ba7c843 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/hashicorp/vault-client-go v0.4.3 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/launchdarkly/go-sdk-common/v3 v3.3.0 github.com/launchdarkly/go-server-sdk/v7 v7.13.0 github.com/lib/pq v1.10.9 github.com/oapi-codegen/runtime v1.1.1 github.com/orcaman/concurrent-map/v2 v2.0.1 + github.com/pkg/errors v0.9.1 github.com/redis/go-redis/v9 v9.12.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/bridges/otelzap v0.13.0 @@ -199,6 +201,7 @@ require ( github.com/hashicorp/go-msgpack v1.1.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect @@ -260,7 +263,6 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/alertmanager v0.26.0 // indirect @@ -272,6 +274,7 @@ require ( github.com/prometheus/procfs v0.16.0 // indirect github.com/prometheus/prometheus v1.8.2-0.20200727090838-6f296594a852 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect diff --git a/packages/shared/go.sum b/packages/shared/go.sum index d00ef152eb..8703182dcd 100644 --- a/packages/shared/go.sum +++ b/packages/shared/go.sum @@ -525,6 +525,8 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= @@ -551,6 +553,8 @@ github.com/hashicorp/nomad/api v0.0.0-20231208134655-099ee06a607c h1:7QPAGE7GJfS github.com/hashicorp/nomad/api v0.0.0-20231208134655-099ee06a607c/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= github.com/hetznercloud/hcloud-go/v2 v2.0.0 h1:Sg1DJ+MAKvbYAqaBaq9tPbwXBS2ckPIaMtVdUjKu+4g= github.com/hetznercloud/hcloud-go/v2 v2.0.0/go.mod h1:4iUG2NG8b61IAwNx6UsMWQ6IfIf/i1RsG0BbsKAyR5Q= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -800,6 +804,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= diff --git a/packages/shared/pkg/feature-flags/flags.go b/packages/shared/pkg/feature-flags/flags.go index 5b8a7a9289..004f5d7ebd 100644 --- a/packages/shared/pkg/feature-flags/flags.go +++ b/packages/shared/pkg/feature-flags/flags.go @@ -47,6 +47,7 @@ var ( SandboxLifeCycleEventsWriteFlagName = newBoolFlag("sandbox-lifecycle-events-write", env.IsDevelopment()) SnapshotFeatureFlagName = newBoolFlag("use-nfs-for-snapshots", env.IsDevelopment()) TemplateFeatureFlagName = newBoolFlag("use-nfs-for-templates", env.IsDevelopment()) + SecretsFeatureFlag = newBoolFlag("secrets-vault", env.IsDevelopment()) SandboxEventsPublishFlagName = newBoolFlag("sandbox-events-publish", env.IsDevelopment()) BestOfKPlacementAlgorithm = newBoolFlag("best-of-k-placement-algorithm", env.IsDevelopment()) BestOfKCanFit = newBoolFlag("best-of-k-can-fit", true) diff --git a/packages/shared/pkg/vault/client.go b/packages/shared/pkg/vault/client.go new file mode 100644 index 0000000000..bf649639f1 --- /dev/null +++ b/packages/shared/pkg/vault/client.go @@ -0,0 +1,300 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +// key used where the actual secret is stored under the given path +const secretKey = "value" + +type Client struct { + client *vault.Client + logger *zap.Logger + renewTicker *time.Ticker + stopRenew chan struct{} + roleID string + secretID string +} + +type ClientConfig struct { + // Vault server address (e.g., "https://vault-leader.service.consul:8200") + Address string + // AppRole Role ID for authentication, see init-vault.sh + RoleID string + // AppRole Secret ID for authentication + SecretID string + // CA certificate to verify server (optional, PEM format) + CACert string + // Logger instance (optional) + Logger *zap.Logger +} + +func NewClient(ctx context.Context, config ClientConfig) (*Client, error) { + // Set defaults + if config.Logger == nil { + config.Logger = zap.NewNop() + } + + tlsConfig := vault.TLSConfiguration{ + InsecureSkipVerify: true, + } + + if config.CACert != "" { + tlsConfig.ServerCertificate = vault.ServerCertificateEntry{ + FromBytes: []byte(config.CACert), + } + tlsConfig.InsecureSkipVerify = false + } + + vaultClient, err := vault.New( + vault.WithAddress(config.Address), + vault.WithRequestTimeout(30*time.Second), + vault.WithTLS(tlsConfig), + vault.WithRetryConfiguration(vault.RetryConfiguration{ + // slightly more aggressive than default with more retries + RetryWaitMin: 50 * time.Millisecond, + RetryWaitMax: 2 * time.Second, + RetryMax: 10, + }), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create vault client") + } + + client := &Client{ + client: vaultClient, + logger: config.Logger, + stopRenew: make(chan struct{}), + roleID: config.RoleID, + secretID: config.SecretID, + } + + // Authenticate with AppRole + leaseDuration, err := client.authenticate(ctx, config.RoleID, config.SecretID) + if err != nil { + return nil, errors.Wrap(err, "failed to authenticate with vault") + } + + // Start token renewal once, based on the initial lease duration + client.startTokenRenewal(ctx, leaseDuration) + + return client, nil +} + +var ( + ErrVaultAddrNotSet = errors.New("VAULT_ADDR environment variable is not set") + ErrVaultRoleIDNotSet = errors.New("VAULT_APPROLE_ROLE_ID environment variable is not set") + ErrVaultSecretIDNotSet = errors.New("VAULT_APPROLE_SECRET_ID environment variable is not set") +) + +func NewClientFromEnv(ctx context.Context) (*Client, error) { + logger, _ := zap.NewProduction() + + config := ClientConfig{ + Address: os.Getenv("VAULT_ADDR"), + RoleID: os.Getenv("VAULT_APPROLE_ROLE_ID"), + SecretID: os.Getenv("VAULT_APPROLE_SECRET_ID"), + CACert: os.Getenv("VAULT_TLS_CA"), + Logger: logger, + } + + if config.Address == "" { + return nil, ErrVaultAddrNotSet + } + if config.RoleID == "" { + return nil, ErrVaultRoleIDNotSet + } + if config.SecretID == "" { + return nil, ErrVaultSecretIDNotSet + } + + return NewClient(ctx, config) +} + +var ErrAuthResponseMissing = errors.New("authentication response missing auth data") + +// authenticate performs AppRole authentication and returns the lease duration +func (c *Client) authenticate(ctx context.Context, roleID, secretID string) (time.Duration, error) { + resp, err := c.client.Auth.AppRoleLogin(ctx, schema.AppRoleLoginRequest{ + RoleId: roleID, + SecretId: secretID, + }) + if err != nil { + return 0, errors.Wrap(err, "failed to authenticate with vault") + } + + if resp == nil || resp.Auth == nil { + return 0, ErrAuthResponseMissing + } + + if err := c.client.SetToken(resp.Auth.ClientToken); err != nil { + return 0, errors.Wrap(err, "failed to set client token") + } + + c.logger.Info("successfully authenticated with vault", + zap.String("accessor", resp.Auth.Accessor), + zap.Int("lease_duration", resp.Auth.LeaseDuration), + ) + + return time.Duration(resp.Auth.LeaseDuration) * time.Second, nil +} + +// startTokenRenewal starts a background goroutine to renew the token +func (c *Client) startTokenRenewal(ctx context.Context, leaseDuration time.Duration) { + if c.renewTicker != nil { + return + } + // Renew at 1/3 of the lease duration + renewInterval := max(leaseDuration/3, time.Minute) + + c.renewTicker = time.NewTicker(renewInterval) + + go func() { + for { + select { + case <-ctx.Done(): + c.logger.Info("stopping token renewal due to context cancellation") + return + case <-c.stopRenew: + c.logger.Info("stopping token renewal") + return + case <-c.renewTicker.C: + // Use a context with timeout for each renewal attempt + renewCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + c.renewToken(renewCtx) + cancel() + } + } + }() +} + +var ErrTokenRenewalFailed = errors.New("failed to renew token") + +// renewToken renews the current authentication token +func (c *Client) renewToken(ctx context.Context) { + resp, err := c.client.Auth.TokenRenewSelf(ctx, schema.TokenRenewSelfRequest{}) + if err != nil { + c.logger.Error("failed to renew token, attempting re-authentication", zap.Error(err)) + if _, authErr := c.authenticate(ctx, c.roleID, c.secretID); authErr != nil { + c.logger.Error("failed to re-authenticate", zap.Error(authErr)) + } + return + } + + // if the token is not renewable, we need to re-authenticate + if resp.Auth != nil && !resp.Auth.Renewable { + c.logger.Info("token is not renewable, attempting re-authentication") + if _, authErr := c.authenticate(ctx, c.roleID, c.secretID); authErr != nil { + c.logger.Error("failed to re-authenticate", zap.Error(authErr)) + } + return + } + + if resp != nil && resp.Auth != nil { + c.logger.Debug("token renewed", + zap.Time("renewed_at", time.Now()), + zap.Int("lease_duration", resp.Auth.LeaseDuration), + ) + } +} + +var ErrSecretNotFound = errors.New("secret not found") + +// GetSecret retrieves a secret and its unseralized metadata from Vault at the specified path +func (c *Client) GetSecret(ctx context.Context, path string) (string, map[string]any, error) { + resp, err := c.client.Secrets.KvV2Read(ctx, path) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + return "", nil, errors.Wrap(err, "failed to read secret") + } + + if resp == nil || resp.Data.Data == nil || vault.IsErrorStatus(err, http.StatusNotFound) { + return "", nil, ErrSecretNotFound + } + + value, ok := resp.Data.Data[secretKey].(string) + if !ok { + return "", nil, ErrSecretNotFound + } + + c.logger.Debug("secret retrieved", + zap.String("path", path), + ) + + return value, resp.Data.Metadata, nil +} + +// WriteSecret writes a secret to Vault at the specified path, metadata will be serialized as key=json +func (c *Client) WriteSecret(ctx context.Context, path string, value string, metadata map[string]any) error { + _, err := c.client.Secrets.KvV2Write(ctx, path, schema.KvV2WriteRequest{ + Data: map[string]any{ + secretKey: value, + }, + }) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to write secret at path %s", path)) + } + + c.logger.Debug("secret written", + zap.String("path", path), + zap.Int("metadata_keys", len(metadata)), + ) + + // metadata must be key:value pairs and value must be string, so make it json + serializedMetadata := make(map[string]any, len(metadata)) + for key, value := range metadata { + valueJsonStr, err := json.Marshal(value) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to marshal metadata value for key %s", key)) + } + serializedMetadata[key] = string(valueJsonStr) + } + + if _, err := c.client.Secrets.KvV2WriteMetadata(ctx, path, schema.KvV2WriteMetadataRequest{ + CasRequired: false, + CustomMetadata: serializedMetadata, + DeleteVersionAfter: time.Duration(0).String(), + MaxVersions: 1, + }); err != nil { + // clean up the secret if metadata write fails + _, err := c.client.Secrets.KvV2Delete(ctx, path) + if err != nil { + c.logger.Error("failed to clean up secret", zap.Error(err)) + } + + return errors.Wrap(err, fmt.Sprintf("failed to write metadata at path %s", path)) + } + + return nil +} + +// DeleteSecret deletes a secret and all its versions from Vault at the specified path +func (c *Client) DeleteSecret(ctx context.Context, path string) error { + // Delete all versions of the secret + _, err := c.client.Secrets.KvV2DeleteMetadataAndAllVersions(ctx, path) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to delete secret at path %s", path)) + } + + c.logger.Debug("secret deleted", zap.String("path", path)) + return nil +} + +// Close stops token renewal and cleans up resources +func (c *Client) Close() { + close(c.stopRenew) + + if c.renewTicker != nil { + c.renewTicker.Stop() + c.renewTicker = nil + } +} diff --git a/packages/shared/pkg/vault/interface.go b/packages/shared/pkg/vault/interface.go new file mode 100644 index 0000000000..245b04b869 --- /dev/null +++ b/packages/shared/pkg/vault/interface.go @@ -0,0 +1,16 @@ +package vault + +import ( + "context" +) + +// If you self-host E2B and don't want to use Hashicorp Vault, you can implement this interface to use your own vault backend +type VaultBackend interface { + GetSecret(ctx context.Context, path string) (string, map[string]any, error) + + WriteSecret(ctx context.Context, path string, value string, metadata map[string]any) error + + DeleteSecret(ctx context.Context, path string) error + + Close() +} diff --git a/spec/openapi.yml b/spec/openapi.yml index 6fb6444786..9d926a09b6 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -75,6 +75,12 @@ components: required: true schema: type: string + secretID: + name: secretID + in: path + required: true + schema: + type: string responses: "400": @@ -193,6 +199,11 @@ components: type: string description: Environment variables for the sandbox + Secrets: + additionalProperties: + type: string + description: Secrets for the sandbox + SandboxLog: description: Log entry with timestamp and line required: @@ -1179,12 +1190,102 @@ components: type: string description: Suffix used in masked version of the token or key + Secret: + required: + - id + - label + - allowlist + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the secret + label: + type: string + description: Label of the secret + description: + type: string + description: Description of the secret + allowlist: + type: array + items: + type: string + description: List of allowlist where this secret can be used + createdAt: + type: string + format: date-time + description: When the secret was created + + CreatedSecret: + required: + - id + - label + - allowlist + - description + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the secret + label: + type: string + description: Label of the secret + description: + type: string + description: Description of the secret + allowlist: + type: array + items: + type: string + description: List of allowlist where this secret can be used + createdAt: + type: string + format: date-time + description: When the secret was created + + NewSecret: + required: + - label + - value + - allowlist + - description + properties: + label: + type: string + description: Label of the secret + value: + type: string + description: Value of the secret + allowlist: + type: array + items: + type: string + description: List of allowlist where this secret can be used + description: + type: string + description: Description of the secret + + UpdateSecret: + required: + - label + - description + properties: + label: + type: string + description: New label for the secret + description: + type: string + description: New description for the secret + tags: - name: templates - name: sandboxes - name: auth - name: access-tokens - name: api-keys + - name: secrets paths: /health: @@ -2158,3 +2259,95 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + + /secrets: + get: + description: List all team secrets + tags: [secrets] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + responses: + "200": + description: List of team secrets + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Secret" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + post: + description: Create a new team secret + tags: [secrets] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewSecret" + responses: + "201": + description: Team secret created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedSecret" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /secrets/{secretID}: + patch: + description: Update a team secret + tags: [secrets] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/secretID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSecret" + responses: + "200": + description: Team secret updated successfully + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + delete: + description: Delete a team secret + tags: [secrets] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/secretID" + responses: + "204": + description: Team secret deleted successfully + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" diff --git a/tests/integration/internal/api/client.gen.go b/tests/integration/internal/api/client.gen.go index b2440d179b..38454d64cc 100644 --- a/tests/integration/internal/api/client.gen.go +++ b/tests/integration/internal/api/client.gen.go @@ -168,6 +168,22 @@ type ClientInterface interface { PostSandboxesSandboxIDTimeout(ctx context.Context, sandboxID SandboxID, body PostSandboxesSandboxIDTimeoutJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetSecrets request + GetSecrets(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostSecretsWithBody request with any body + PostSecretsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostSecrets(ctx context.Context, body PostSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteSecretsSecretID request + DeleteSecretsSecretID(ctx context.Context, secretID SecretID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PatchSecretsSecretIDWithBody request with any body + PatchSecretsSecretIDWithBody(ctx context.Context, secretID SecretID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PatchSecretsSecretID(ctx context.Context, secretID SecretID, body PatchSecretsSecretIDJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetTeams request GetTeams(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -569,6 +585,78 @@ func (c *Client) PostSandboxesSandboxIDTimeout(ctx context.Context, sandboxID Sa return c.Client.Do(req) } +func (c *Client) GetSecrets(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetSecretsRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostSecretsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostSecretsRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostSecrets(ctx context.Context, body PostSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostSecretsRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteSecretsSecretID(ctx context.Context, secretID SecretID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteSecretsSecretIDRequest(c.Server, secretID) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PatchSecretsSecretIDWithBody(ctx context.Context, secretID SecretID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPatchSecretsSecretIDRequestWithBody(c.Server, secretID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PatchSecretsSecretID(ctx context.Context, secretID SecretID, body PatchSecretsSecretIDJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPatchSecretsSecretIDRequest(c.Server, secretID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetTeams(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetTeamsRequest(c.Server) if err != nil { @@ -1697,6 +1785,154 @@ func NewPostSandboxesSandboxIDTimeoutRequestWithBody(server string, sandboxID Sa return req, nil } +// NewGetSecretsRequest generates requests for GetSecrets +func NewGetSecretsRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/secrets") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostSecretsRequest calls the generic PostSecrets builder with application/json body +func NewPostSecretsRequest(server string, body PostSecretsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostSecretsRequestWithBody(server, "application/json", bodyReader) +} + +// NewPostSecretsRequestWithBody generates requests for PostSecrets with any type of body +func NewPostSecretsRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/secrets") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteSecretsSecretIDRequest generates requests for DeleteSecretsSecretID +func NewDeleteSecretsSecretIDRequest(server string, secretID SecretID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "secretID", runtime.ParamLocationPath, secretID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/secrets/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPatchSecretsSecretIDRequest calls the generic PatchSecretsSecretID builder with application/json body +func NewPatchSecretsSecretIDRequest(server string, secretID SecretID, body PatchSecretsSecretIDJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPatchSecretsSecretIDRequestWithBody(server, secretID, "application/json", bodyReader) +} + +// NewPatchSecretsSecretIDRequestWithBody generates requests for PatchSecretsSecretID with any type of body +func NewPatchSecretsSecretIDRequestWithBody(server string, secretID SecretID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "secretID", runtime.ParamLocationPath, secretID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/secrets/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetTeamsRequest generates requests for GetTeams func NewGetTeamsRequest(server string) (*http.Request, error) { var err error @@ -2571,6 +2807,22 @@ type ClientWithResponsesInterface interface { PostSandboxesSandboxIDTimeoutWithResponse(ctx context.Context, sandboxID SandboxID, body PostSandboxesSandboxIDTimeoutJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSandboxesSandboxIDTimeoutResponse, error) + // GetSecretsWithResponse request + GetSecretsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSecretsResponse, error) + + // PostSecretsWithBodyWithResponse request with any body + PostSecretsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSecretsResponse, error) + + PostSecretsWithResponse(ctx context.Context, body PostSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSecretsResponse, error) + + // DeleteSecretsSecretIDWithResponse request + DeleteSecretsSecretIDWithResponse(ctx context.Context, secretID SecretID, reqEditors ...RequestEditorFn) (*DeleteSecretsSecretIDResponse, error) + + // PatchSecretsSecretIDWithBodyWithResponse request with any body + PatchSecretsSecretIDWithBodyWithResponse(ctx context.Context, secretID SecretID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchSecretsSecretIDResponse, error) + + PatchSecretsSecretIDWithResponse(ctx context.Context, secretID SecretID, body PatchSecretsSecretIDJSONRequestBody, reqEditors ...RequestEditorFn) (*PatchSecretsSecretIDResponse, error) + // GetTeamsWithResponse request GetTeamsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetTeamsResponse, error) @@ -3136,6 +3388,103 @@ func (r PostSandboxesSandboxIDTimeoutResponse) StatusCode() int { return 0 } +type GetSecretsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]Secret + JSON401 *N401 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r GetSecretsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetSecretsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostSecretsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreatedSecret + JSON401 *N401 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r PostSecretsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostSecretsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteSecretsSecretIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON401 *N401 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r DeleteSecretsSecretIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteSecretsSecretIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PatchSecretsSecretIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *N400 + JSON401 *N401 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r PatchSecretsSecretIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PatchSecretsSecretIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetTeamsResponse struct { Body []byte HTTPResponse *http.Response @@ -3732,6 +4081,58 @@ func (c *ClientWithResponses) PostSandboxesSandboxIDTimeoutWithResponse(ctx cont return ParsePostSandboxesSandboxIDTimeoutResponse(rsp) } +// GetSecretsWithResponse request returning *GetSecretsResponse +func (c *ClientWithResponses) GetSecretsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSecretsResponse, error) { + rsp, err := c.GetSecrets(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetSecretsResponse(rsp) +} + +// PostSecretsWithBodyWithResponse request with arbitrary body returning *PostSecretsResponse +func (c *ClientWithResponses) PostSecretsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSecretsResponse, error) { + rsp, err := c.PostSecretsWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostSecretsResponse(rsp) +} + +func (c *ClientWithResponses) PostSecretsWithResponse(ctx context.Context, body PostSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSecretsResponse, error) { + rsp, err := c.PostSecrets(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostSecretsResponse(rsp) +} + +// DeleteSecretsSecretIDWithResponse request returning *DeleteSecretsSecretIDResponse +func (c *ClientWithResponses) DeleteSecretsSecretIDWithResponse(ctx context.Context, secretID SecretID, reqEditors ...RequestEditorFn) (*DeleteSecretsSecretIDResponse, error) { + rsp, err := c.DeleteSecretsSecretID(ctx, secretID, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteSecretsSecretIDResponse(rsp) +} + +// PatchSecretsSecretIDWithBodyWithResponse request with arbitrary body returning *PatchSecretsSecretIDResponse +func (c *ClientWithResponses) PatchSecretsSecretIDWithBodyWithResponse(ctx context.Context, secretID SecretID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchSecretsSecretIDResponse, error) { + rsp, err := c.PatchSecretsSecretIDWithBody(ctx, secretID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePatchSecretsSecretIDResponse(rsp) +} + +func (c *ClientWithResponses) PatchSecretsSecretIDWithResponse(ctx context.Context, secretID SecretID, body PatchSecretsSecretIDJSONRequestBody, reqEditors ...RequestEditorFn) (*PatchSecretsSecretIDResponse, error) { + rsp, err := c.PatchSecretsSecretID(ctx, secretID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePatchSecretsSecretIDResponse(rsp) +} + // GetTeamsWithResponse request returning *GetTeamsResponse func (c *ClientWithResponses) GetTeamsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetTeamsResponse, error) { rsp, err := c.GetTeams(ctx, reqEditors...) @@ -4794,6 +5195,173 @@ func ParsePostSandboxesSandboxIDTimeoutResponse(rsp *http.Response) (*PostSandbo return response, nil } +// ParseGetSecretsResponse parses an HTTP response from a GetSecretsWithResponse call +func ParseGetSecretsResponse(rsp *http.Response) (*GetSecretsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetSecretsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []Secret + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostSecretsResponse parses an HTTP response from a PostSecretsWithResponse call +func ParsePostSecretsResponse(rsp *http.Response) (*PostSecretsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostSecretsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreatedSecret + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteSecretsSecretIDResponse parses an HTTP response from a DeleteSecretsSecretIDWithResponse call +func ParseDeleteSecretsSecretIDResponse(rsp *http.Response) (*DeleteSecretsSecretIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteSecretsSecretIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePatchSecretsSecretIDResponse parses an HTTP response from a PatchSecretsSecretIDWithResponse call +func ParsePatchSecretsSecretIDResponse(rsp *http.Response) (*PatchSecretsSecretIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PatchSecretsSecretIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetTeamsResponse parses an HTTP response from a GetTeamsWithResponse call func ParseGetTeamsResponse(rsp *http.Response) (*GetTeamsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/tests/integration/internal/api/models.gen.go b/tests/integration/internal/api/models.gen.go index cc69e13be9..0a0ea94d09 100644 --- a/tests/integration/internal/api/models.gen.go +++ b/tests/integration/internal/api/models.gen.go @@ -135,6 +135,24 @@ type CreatedAccessToken struct { Token string `json:"token"` } +// CreatedSecret defines model for CreatedSecret. +type CreatedSecret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // CreatedAt When the secret was created + CreatedAt time.Time `json:"createdAt"` + + // Description Description of the secret + Description string `json:"description"` + + // Id Identifier of the secret + Id openapi_types.UUID `json:"id"` + + // Label Label of the secret + Label string `json:"label"` +} + // CreatedTeamAPIKey defines model for CreatedTeamAPIKey. type CreatedTeamAPIKey struct { // CreatedAt Timestamp of API key creation @@ -321,6 +339,21 @@ type NewSandbox struct { Timeout *int32 `json:"timeout,omitempty"` } +// NewSecret defines model for NewSecret. +type NewSecret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // Description Description of the secret + Description string `json:"description"` + + // Label Label of the secret + Label string `json:"label"` + + // Value Value of the secret + Value string `json:"value"` +} + // NewTeamAPIKey defines model for NewTeamAPIKey. type NewTeamAPIKey struct { // Name Name of the API key @@ -593,6 +626,24 @@ type SandboxesWithMetrics struct { Sandboxes map[string]SandboxMetric `json:"sandboxes"` } +// Secret defines model for Secret. +type Secret struct { + // Allowlist List of allowlist where this secret can be used + Allowlist []string `json:"allowlist"` + + // CreatedAt When the secret was created + CreatedAt time.Time `json:"createdAt"` + + // Description Description of the secret + Description *string `json:"description,omitempty"` + + // Id Identifier of the secret + Id openapi_types.UUID `json:"id"` + + // Label Label of the secret + Label string `json:"label"` +} + // Team defines model for Team. type Team struct { // ApiKey API key for the team @@ -806,6 +857,15 @@ type TemplateUpdateRequest struct { Public *bool `json:"public,omitempty"` } +// UpdateSecret defines model for UpdateSecret. +type UpdateSecret struct { + // Description New description for the secret + Description string `json:"description"` + + // Label New label for the secret + Label string `json:"label"` +} + // UpdateTeamAPIKey defines model for UpdateTeamAPIKey. type UpdateTeamAPIKey struct { // Name New name for the API key @@ -827,6 +887,9 @@ type NodeID = string // SandboxID defines model for sandboxID. type SandboxID = string +// SecretID defines model for secretID. +type SecretID = string + // TeamID defines model for teamID. type TeamID = string @@ -968,6 +1031,12 @@ type PostSandboxesSandboxIDResumeJSONRequestBody = ResumedSandbox // PostSandboxesSandboxIDTimeoutJSONRequestBody defines body for PostSandboxesSandboxIDTimeout for application/json ContentType. type PostSandboxesSandboxIDTimeoutJSONRequestBody PostSandboxesSandboxIDTimeoutJSONBody +// PostSecretsJSONRequestBody defines body for PostSecrets for application/json ContentType. +type PostSecretsJSONRequestBody = NewSecret + +// PatchSecretsSecretIDJSONRequestBody defines body for PatchSecretsSecretID for application/json ContentType. +type PatchSecretsSecretIDJSONRequestBody = UpdateSecret + // PostTemplatesJSONRequestBody defines body for PostTemplates for application/json ContentType. type PostTemplatesJSONRequestBody = TemplateBuildRequest diff --git a/tests/integration/internal/tests/api/apikey_test.go b/tests/integration/internal/tests/api/apikey_test.go index a0fd1883ab..c6beda715f 100644 --- a/tests/integration/internal/tests/api/apikey_test.go +++ b/tests/integration/internal/tests/api/apikey_test.go @@ -43,7 +43,7 @@ func TestCreateAPIKeyForeignTeam(t *testing.T) { c := setup.GetAPIClient() // Create first team and API key - foreignTeamID := utils.CreateTeam(t, c, db, "test-team-apikey-foreign") + foreignTeamID := utils.CreateTeam(t, ctx, c, db, "test-team-apikey-foreign") // Create the API key resp, err := c.PostApiKeysWithResponse(ctx, api.PostApiKeysJSONRequestBody{ @@ -61,7 +61,7 @@ func TestCreateAPIKeyForeignTeamWithCache(t *testing.T) { // Create first team foreignUserID := utils.CreateUser(t, db) - foreignTeamID := utils.CreateTeamWithUser(t, c, db, "test-team-apikey-foreign-cache", foreignUserID.String()) + foreignTeamID := utils.CreateTeamWithUser(t, ctx, c, db, "test-team-apikey-foreign-cache", foreignUserID.String()) // Populate cache by calling some endpoint utils.CreateAPIKey(t, ctx, c, foreignUserID.String(), foreignTeamID) @@ -112,10 +112,10 @@ func TestDeleteAPIKey(t *testing.T) { c := setup.GetAPIClient() // Create first team and API key - teamID1 := utils.CreateTeamWithUser(t, c, db, "test-team-apikey-delete-1", setup.UserID) + teamID1 := utils.CreateTeamWithUser(t, ctx, c, db, "test-team-apikey-delete-1", setup.UserID) // Create second team and API key - teamID2 := utils.CreateTeamWithUser(t, c, db, "test-team-apikey-delete-2", setup.UserID) + teamID2 := utils.CreateTeamWithUser(t, ctx, c, db, "test-team-apikey-delete-2", setup.UserID) // Create an additional API key for team1 resp, err := c.PostApiKeysWithResponse(ctx, api.PostApiKeysJSONRequestBody{ @@ -248,10 +248,10 @@ func TestPatchAPIKey(t *testing.T) { c := setup.GetAPIClient() // Create first team and API key - teamID1 := utils.CreateTeamWithUser(t, c, db, "test-team-apikey-patch-1", setup.UserID) + teamID1 := utils.CreateTeamWithUser(t, ctx, c, db, "test-team-apikey-patch-1", setup.UserID) // Create second team and API key - teamID2 := utils.CreateTeamWithUser(t, c, db, "test-team-apikey-patch-2", setup.UserID) + teamID2 := utils.CreateTeamWithUser(t, ctx, c, db, "test-team-apikey-patch-2", setup.UserID) // Create an additional API key for team1 resp, err := c.PostApiKeysWithResponse(ctx, api.PostApiKeysJSONRequestBody{ diff --git a/tests/integration/internal/tests/api/auth/supabase_test.go b/tests/integration/internal/tests/api/auth/supabase_test.go index 0d3ec5678b..fdbca3dd6f 100644 --- a/tests/integration/internal/tests/api/auth/supabase_test.go +++ b/tests/integration/internal/tests/api/auth/supabase_test.go @@ -76,7 +76,7 @@ func TestSandboxCreateWithForeignTeamAccess(t *testing.T) { c := setup.GetAPIClient() userID2 := utils.CreateUser(t, db) - teamID2 := utils.CreateTeamWithUser(t, c, db, "test-team-2", userID2.String()) + teamID2 := utils.CreateTeamWithUser(t, t.Context(), c, db, "test-team-2", userID2.String()) t.Run("Fail when using first user token with second team ID", func(t *testing.T) { // This should fail because the first user doesn't belong to the second team diff --git a/tests/integration/internal/tests/api/metrics/team_metrics_max_test.go b/tests/integration/internal/tests/api/metrics/team_metrics_max_test.go index 4e94b1703e..df73c76a3c 100644 --- a/tests/integration/internal/tests/api/metrics/team_metrics_max_test.go +++ b/tests/integration/internal/tests/api/metrics/team_metrics_max_test.go @@ -95,7 +95,7 @@ func TestTeamMetricsMaxEmpty(t *testing.T) { db := setup.GetTestDBClient(t) - teamID := utils.CreateTeamWithUser(t, c, db, "metric-test", setup.UserID) + teamID := utils.CreateTeamWithUser(t, t.Context(), c, db, "metric-test", setup.UserID) tests := []struct { metric api.GetTeamsTeamIDMetricsMaxParamsMetric diff --git a/tests/integration/internal/tests/api/metrics/team_metrics_test.go b/tests/integration/internal/tests/api/metrics/team_metrics_test.go index aaf8077e88..e3339f338a 100644 --- a/tests/integration/internal/tests/api/metrics/team_metrics_test.go +++ b/tests/integration/internal/tests/api/metrics/team_metrics_test.go @@ -105,7 +105,7 @@ func TestTeamMetricsEmpty(t *testing.T) { c := setup.GetAPIClient() db := setup.GetTestDBClient(t) - teamID := utils.CreateTeamWithUser(t, c, db, "test-team-no-metrics", setup.UserID) + teamID := utils.CreateTeamWithUser(t, t.Context(), c, db, "test-team-no-metrics", setup.UserID) response, err := c.GetTeamsTeamIDMetricsWithResponse( t.Context(), diff --git a/tests/integration/internal/tests/api/secrets_test.go b/tests/integration/internal/tests/api/secrets_test.go new file mode 100644 index 0000000000..5d1dca57f8 --- /dev/null +++ b/tests/integration/internal/tests/api/secrets_test.go @@ -0,0 +1,982 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/e2b-dev/infra/tests/integration/internal/api" + "github.com/e2b-dev/infra/tests/integration/internal/setup" + "github.com/e2b-dev/infra/tests/integration/internal/utils" +) + +func TestCreateSecret(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + c := setup.GetAPIClient() + + t.Run("succeeds with valid allowlist", func(t *testing.T) { + // Create the secret + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret", + Value: "secret-value-123", + Description: "Test secret description", + Allowlist: []string{"*.example.com", "api.test.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, "test-secret", resp.JSON201.Label) + assert.Equal(t, "Test secret description", resp.JSON201.Description) + assert.NotEmpty(t, resp.JSON201.Id) + assert.Equal(t, []string{"*.example.com", "api.test.com"}, resp.JSON201.Allowlist) + }) + + t.Run("succeeds with empty allowlist defaults to wildcard", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-empty-allowlist", + Value: "secret-value-456", + Description: "Test with empty allowlist", + Allowlist: []string{}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, []string{"*"}, resp.JSON201.Allowlist) + }) + + t.Run("succeeds with wildcard patterns", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-wildcards", + Value: "secret-value-789", + Description: "Test with various wildcard patterns", + Allowlist: []string{"*", "*.domain.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, []string{"*", "*.domain.com"}, resp.JSON201.Allowlist) + }) + + t.Run("fails with glob question mark pattern", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-glob-question", + Value: "secret-value", + Description: "Test with glob question mark", + Allowlist: []string{"api-?.test.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with glob bracket pattern", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-glob-bracket", + Value: "secret-value", + Description: "Test with glob bracket pattern", + Allowlist: []string{"service[1-3].example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with too many hosts in allowlist", func(t *testing.T) { + tooManyHosts := make([]string, 11) + for i := range 11 { + tooManyHosts[i] = fmt.Sprintf("host%d.example.com", i) + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-too-many-hosts", + Value: "secret-value", + Description: "Test with too many hosts", + Allowlist: tooManyHosts, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "Too many hosts in allowlist") + }) + + t.Run("fails with invalid host pattern", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-invalid-pattern", + Value: "secret-value", + Description: "Test with invalid pattern", + Allowlist: []string{"[invalid-pattern"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with invalid bracket pattern", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-invalid-brackets", + Value: "secret-value", + Description: "Test with unclosed brackets", + Allowlist: []string{"host[1-3.example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with backslash in pattern", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-backslash", + Value: "secret-value", + Description: "Test with backslash", + Allowlist: []string{"host\\example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with URL scheme https", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-https-scheme", + Value: "secret-value", + Description: "Test with https scheme", + Allowlist: []string{"https://example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "cannot contain schemes") + }) + + t.Run("fails with URL scheme http", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-http-scheme", + Value: "secret-value", + Description: "Test with http scheme", + Allowlist: []string{"http://example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "cannot contain schemes") + }) + + t.Run("fails with URL path", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-url-path", + Value: "secret-value", + Description: "Test with URL path", + Allowlist: []string{"example.com/api"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "cannot contain schemes") + }) + + t.Run("fails with port number", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-port", + Value: "secret-value", + Description: "Test with port number", + Allowlist: []string{"example.com:8080"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with invalid characters", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-invalid-chars", + Value: "secret-value", + Description: "Test with invalid characters", + Allowlist: []string{"example!.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with spaces", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-spaces", + Value: "secret-value", + Description: "Test with spaces", + Allowlist: []string{"example .com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("succeeds with valid hostnames", func(t *testing.T) { + validHostnames := []string{ + "example.com", + "sub.example.com", + "sub-domain.example.com", + "example123.com", + "123-example.com", + "a.b.c.d.example.com", + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-valid-hostnames", + Value: "secret-value", + Description: "Test with valid hostnames", + Allowlist: validHostnames, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, validHostnames, resp.JSON201.Allowlist) + }) + + t.Run("succeeds with valid wildcard patterns", func(t *testing.T) { + validWildcards := []string{ + "*", + "*.example.com", + "*.*.example.com", + "api.*.example.com", + "*.*", + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-valid-wildcards", + Value: "secret-value", + Description: "Test with valid wildcard patterns", + Allowlist: validWildcards, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, validWildcards, resp.JSON201.Allowlist) + }) + + t.Run("fails with hostname starting with hyphen", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-hyphen-start", + Value: "secret-value", + Description: "Test with hostname starting with hyphen", + Allowlist: []string{"-example.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with hostname ending with hyphen", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-secret-hyphen-end", + Value: "secret-value", + Description: "Test with hostname ending with hyphen", + Allowlist: []string{"example-.com"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "invalid hostname pattern") + }) + + t.Run("fails with empty secret value", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-empty-value", + Value: "", + Description: "Test with empty secret value", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "Secret value cannot be empty") + }) + + t.Run("fails with secret value exceeding 8KB", func(t *testing.T) { + // Create a value larger than 8KB (8192 bytes) + largeValue := "" + for range 8193 { + largeValue += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-large-value", + Value: largeValue, + Description: "Test with large secret value", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + assert.Contains(t, string(resp.Body), "Secret value cannot exceed") + }) + + t.Run("succeeds with secret value exactly 8KB", func(t *testing.T) { + // Create a value exactly 8KB (8192 bytes) + maxValue := "" + for range 8192 { + maxValue += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-max-value", + Value: maxValue, + Description: "Test with max secret value", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.NotEmpty(t, resp.JSON201.Id) + }) + + t.Run("fails with empty label", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "", + Value: "secret-value", + Description: "Test with empty label", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + }) + + t.Run("fails with label exceeding 256 characters", func(t *testing.T) { + longLabel := "" + for range 257 { + longLabel += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: longLabel, + Value: "secret-value", + Description: "Test with long label", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + }) + + t.Run("succeeds with label exactly 256 characters", func(t *testing.T) { + maxLabel := "" + for range 256 { + maxLabel += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: maxLabel, + Value: "secret-value", + Description: "Test with max label", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, maxLabel, resp.JSON201.Label) + }) + + t.Run("fails with description exceeding 1024 characters", func(t *testing.T) { + longDescription := "" + for range 1025 { + longDescription += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-long-description", + Value: "secret-value", + Description: longDescription, + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode()) + }) + + t.Run("succeeds with description exactly 1024 characters", func(t *testing.T) { + maxDescription := "" + for range 1024 { + maxDescription += "a" + } + + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-max-description", + Value: "secret-value", + Description: maxDescription, + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusCreated, resp.StatusCode()) + assert.Equal(t, maxDescription, resp.JSON201.Description) + }) + + t.Run("fails without API key", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-no-auth", + Value: "secret-value", + Description: "Test without authentication", + Allowlist: []string{"*"}, + }) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode()) + }) + + t.Run("fails with invalid API key", func(t *testing.T) { + resp, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-invalid-auth", + Value: "secret-value", + Description: "Test with invalid authentication", + Allowlist: []string{"*"}, + }, setup.WithAPIKey("invalid-api-key-789")) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode()) + }) +} + +func TestUpdateSecret(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + c := setup.GetAPIClient() + + t.Run("succeeds with valid data", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-original", + Value: "secret-value-original", + Description: "Original description", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + // Update the secret + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "test-update-new", + Description: "Updated description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respU.StatusCode()) + + // Verify the changes by listing secrets + respL, err := c.GetSecretsWithResponse(ctx, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respL.StatusCode()) + + // Find the updated secret + var found bool + for _, secret := range *respL.JSON200 { + if secret.Id == respC.JSON201.Id { + found = true + assert.Equal(t, "test-update-new", secret.Label) + assert.NotNil(t, secret.Description) + assert.Equal(t, "Updated description", *secret.Description) + break + } + } + assert.True(t, found, "Updated secret should be in the list") + }) + + t.Run("fails with empty label", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-empty-label", + Value: "secret-value", + Description: "Test secret", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + // Try to update with empty label + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "", + Description: "Description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusBadRequest, respU.StatusCode()) + assert.Contains(t, string(respU.Body), "Label cannot be empty") + }) + + t.Run("fails with label exceeding 256 characters", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-long-label", + Value: "secret-value", + Description: "Test secret", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + longLabel := "" + for range 257 { + longLabel += "a" + } + + // Try to update with long label + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: longLabel, + Description: "Description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusBadRequest, respU.StatusCode()) + assert.Contains(t, string(respU.Body), "Label cannot exceed") + }) + + t.Run("succeeds with label exactly 256 characters", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-max-label", + Value: "secret-value", + Description: "Test secret", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + maxLabel := "" + for range 256 { + maxLabel += "a" + } + + // Update with max label + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: maxLabel, + Description: "Description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respU.StatusCode()) + }) + + t.Run("fails with description exceeding 1024 characters", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-long-desc", + Value: "secret-value", + Description: "Test secret", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + longDescription := "" + for range 1025 { + longDescription += "a" + } + + // Try to update with long description + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "Label", + Description: longDescription, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusBadRequest, respU.StatusCode()) + assert.Contains(t, string(respU.Body), "Description cannot exceed") + }) + + t.Run("succeeds with description exactly 1024 characters", func(t *testing.T) { + // Create a secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-update-max-desc", + Value: "secret-value", + Description: "Test secret", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + maxDescription := "" + for range 1024 { + maxDescription += "a" + } + + // Update with max description + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "Label", + Description: maxDescription, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respU.StatusCode()) + }) + + t.Run("fails when secret does not exist", func(t *testing.T) { + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, uuid.New().String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "New label", + Description: "New description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusNotFound, respU.StatusCode()) + assert.Contains(t, string(respU.Body), "Secret not found") + }) + + t.Run("cannot update secret from another team", func(t *testing.T) { + db := setup.GetTestDBClient(t) + + // Create a second team with its own API key + team2ID := utils.CreateTeam(t, ctx, c, db, "test-team-secrets-update-foreign") + utils.AddUserToTeam(t, ctx, c, db, team2ID, setup.UserID) + team2APIKey := utils.CreateAPIKey(t, ctx, c, setup.UserID, team2ID) + + // Create a secret on team2 + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "team2-secret-update", + Value: "secret-from-team2-update", + Description: "Secret belonging to team2", + Allowlist: []string{"*"}, + }, setup.WithAPIKey(team2APIKey)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + // Try to update team2's secret using the default team's API key + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "Updated label", + Description: "Updated description", + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusNotFound, respU.StatusCode(), "Should not be able to update another team's secret") + }) + + t.Run("fails without API key", func(t *testing.T) { + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, uuid.New().String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "New label", + Description: "New description", + }) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, respU.StatusCode()) + }) + + t.Run("fails with invalid API key", func(t *testing.T) { + respU, err := c.PatchSecretsSecretIDWithResponse(ctx, uuid.New().String(), api.PatchSecretsSecretIDJSONRequestBody{ + Label: "New label", + Description: "New description", + }, setup.WithAPIKey("invalid-api-key-123")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, respU.StatusCode()) + }) +} + +func TestDeleteSecret(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + c := setup.GetAPIClient() + + t.Run("succeeds", func(t *testing.T) { + // Create the secret + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-delete", + Value: "secret-to-delete", + Description: "Will be deleted", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + // Delete the secret + respD, err := c.DeleteSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusNoContent, respD.StatusCode()) + }) + + t.Run("id does not exist", func(t *testing.T) { + respD, err := c.DeleteSecretsSecretIDWithResponse(ctx, uuid.New().String(), setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusNotFound, respD.StatusCode()) + }) + + t.Run("cannot delete secret from another team", func(t *testing.T) { + db := setup.GetTestDBClient(t) + + // Create a second team with its own API key + team2ID := utils.CreateTeam(t, ctx, c, db, "test-team-secrets-foreign") + utils.AddUserToTeam(t, ctx, c, db, team2ID, setup.UserID) + team2APIKey := utils.CreateAPIKey(t, ctx, c, setup.UserID, team2ID) + + // Create a secret on team2 + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "team2-secret", + Value: "secret-from-team2", + Description: "Secret belonging to team2", + Allowlist: []string{"*"}, + }, setup.WithAPIKey(team2APIKey)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + // Try to delete team2's secret using the default team's API key + respD, err := c.DeleteSecretsSecretIDWithResponse(ctx, respC.JSON201.Id.String(), setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusNotFound, respD.StatusCode(), "Should not be able to delete another team's secret") + }) + + t.Run("fails without API key", func(t *testing.T) { + respD, err := c.DeleteSecretsSecretIDWithResponse(ctx, uuid.New().String()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, respD.StatusCode()) + }) + + t.Run("fails with invalid API key", func(t *testing.T) { + respD, err := c.DeleteSecretsSecretIDWithResponse(ctx, uuid.New().String(), setup.WithAPIKey("invalid-api-key-123")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, respD.StatusCode()) + }) +} + +func TestListSecrets(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + c := setup.GetAPIClient() + + t.Run("succeeds", func(t *testing.T) { + resp, err := c.GetSecretsWithResponse(ctx, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, resp.StatusCode()) + assert.NotNil(t, resp.JSON200) + }) + + t.Run("returns secrets in descending order by creation time", func(t *testing.T) { + // Create three secrets in sequence + secret1, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-order-first", + Value: "value1", + Description: "Created first", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, secret1.StatusCode()) + + secret2, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-order-second", + Value: "value2", + Description: "Created second", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, secret2.StatusCode()) + + secret3, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "test-order-third", + Value: "value3", + Description: "Created third", + Allowlist: []string{"*"}, + }, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, secret3.StatusCode()) + + // List all secrets + respL, err := c.GetSecretsWithResponse(ctx, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respL.StatusCode()) + assert.NotNil(t, respL.JSON200) + + // Find our three test secrets in the response + var foundSecrets []api.Secret + for _, secret := range *respL.JSON200 { + if secret.Id == secret1.JSON201.Id || secret.Id == secret2.JSON201.Id || secret.Id == secret3.JSON201.Id { + foundSecrets = append(foundSecrets, secret) + } + } + + // Should have found all three + assert.Len(t, foundSecrets, 3, "Should find all three created secrets") + + // Verify they are in descending order by creation time (newest first) + assert.True(t, foundSecrets[0].CreatedAt.After(foundSecrets[1].CreatedAt) || foundSecrets[0].CreatedAt.Equal(foundSecrets[1].CreatedAt), + "First secret should be created after or at same time as second") + assert.True(t, foundSecrets[1].CreatedAt.After(foundSecrets[2].CreatedAt) || foundSecrets[1].CreatedAt.Equal(foundSecrets[2].CreatedAt), + "Second secret should be created after or at same time as third") + + // Specifically check that the newest one (secret3) is first + assert.Equal(t, secret3.JSON201.Id, foundSecrets[0].Id, "Newest secret (third created) should be first in list") + assert.Equal(t, secret2.JSON201.Id, foundSecrets[1].Id, "Second newest secret should be second in list") + assert.Equal(t, secret1.JSON201.Id, foundSecrets[2].Id, "Oldest secret (first created) should be last in list") + }) + + t.Run("cannot see secrets from another team", func(t *testing.T) { + db := setup.GetTestDBClient(t) + + // Create a second team with its own API key + team2ID := utils.CreateTeam(t, ctx, c, db, "test-team-secrets-list-foreign") + utils.AddUserToTeam(t, ctx, c, db, team2ID, setup.UserID) + team2APIKey := utils.CreateAPIKey(t, ctx, c, setup.UserID, team2ID) + + // Create a secret on team2 + respC, err := c.PostSecretsWithResponse(ctx, api.PostSecretsJSONRequestBody{ + Label: "team2-secret-list", + Value: "secret-from-team2-list", + Description: "Secret belonging to team2 for list test", + Allowlist: []string{"*"}, + }, setup.WithAPIKey(team2APIKey)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, respC.StatusCode()) + + team2SecretID := respC.JSON201.Id + + // List secrets using the default team's API key + respL, err := c.GetSecretsWithResponse(ctx, setup.WithAPIKey()) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusOK, respL.StatusCode()) + + // Verify team2's secret is NOT in the list + for _, secret := range *respL.JSON200 { + assert.NotEqual(t, team2SecretID, secret.Id, "Should not see secrets from another team") + } + }) + + t.Run("fails without API key", func(t *testing.T) { + resp, err := c.GetSecretsWithResponse(ctx) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode()) + }) + + t.Run("fails with invalid API key", func(t *testing.T) { + resp, err := c.GetSecretsWithResponse(ctx, setup.WithAPIKey("invalid-api-key-456")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode()) + }) +} diff --git a/tests/integration/internal/tests/team_test.go b/tests/integration/internal/tests/team_test.go index 5a3702564c..742ce4b6e6 100644 --- a/tests/integration/internal/tests/team_test.go +++ b/tests/integration/internal/tests/team_test.go @@ -24,7 +24,7 @@ func TestBannedTeam(t *testing.T) { c := setup.GetAPIClient() teamName := "test-team-banned" - teamID := utils.CreateTeamWithUser(t, c, db, teamName, setup.UserID) + teamID := utils.CreateTeamWithUser(t, ctx, c, db, teamName, setup.UserID) apiKey := utils.CreateAPIKey(t, ctx, c, setup.UserID, teamID) err := db.Client.Team.UpdateOneID(teamID).SetIsBanned(true).Exec(ctx) @@ -55,7 +55,7 @@ func TestBlockedTeam(t *testing.T) { teamName := "test-team-blocked" blockReason := "test-reason" - teamID := utils.CreateTeamWithUser(t, c, db, teamName, setup.UserID) + teamID := utils.CreateTeamWithUser(t, ctx, c, db, teamName, setup.UserID) apiKey := utils.CreateAPIKey(t, ctx, c, setup.UserID, teamID) err := db.Client.Team.UpdateOneID(teamID).SetIsBlocked(true).SetBlockedReason(blockReason).Exec(ctx) diff --git a/tests/integration/internal/utils/team.go b/tests/integration/internal/utils/team.go index 9efc66c36d..1db0f7cc3d 100644 --- a/tests/integration/internal/utils/team.go +++ b/tests/integration/internal/utils/team.go @@ -15,14 +15,15 @@ import ( "github.com/e2b-dev/infra/tests/integration/internal/setup" ) -func CreateTeam(t *testing.T, c *api.ClientWithResponses, db *db.DB, teamName string) uuid.UUID { +func CreateTeam(t *testing.T, ctx context.Context, c *api.ClientWithResponses, db *db.DB, teamName string) uuid.UUID { t.Helper() - return CreateTeamWithUser(t, c, db, teamName, "") + return CreateTeamWithUser(t, ctx, c, db, teamName, "") } func CreateTeamWithUser( t *testing.T, + ctx context.Context, c *api.ClientWithResponses, db *db.DB, teamName string, @@ -32,25 +33,27 @@ func CreateTeamWithUser( teamID := uuid.New() - team, err := db.Client.Team.Create().SetID(teamID).SetEmail(fmt.Sprintf("test-integration-%s@e2b.dev", teamID)).SetName(teamName).SetTier("base_v1").Save(t.Context()) + team, err := db.Client.Team.Create().SetID(teamID).SetEmail(fmt.Sprintf("test-integration-%s@e2b.dev", teamID)).SetName(teamName).SetTier("base_v1").Save(ctx) require.NoError(t, err) assert.Equal(t, teamName, team.Name) assert.Equal(t, teamID, team.ID) if userID != "" { - AddUserToTeam(t, c, db, teamID, userID) + AddUserToTeam(t, ctx, c, db, teamID, userID) } + // Cleanup should use background context as test context may be canceled + //nolint:contextcheck t.Cleanup(func() { - db.Client.Team.DeleteOneID(teamID).Exec(t.Context()) - db.Client.TeamAPIKey.DeleteOneID(teamID).Exec(t.Context()) + db.Client.Team.DeleteOneID(teamID).Exec(context.Background()) + db.Client.TeamAPIKey.DeleteOneID(teamID).Exec(context.Background()) }) return team.ID } -func AddUserToTeam(t *testing.T, c *api.ClientWithResponses, db *db.DB, teamID uuid.UUID, userID string) { +func AddUserToTeam(t *testing.T, ctx context.Context, c *api.ClientWithResponses, db *db.DB, teamID uuid.UUID, userID string) { t.Helper() userUUID, err := uuid.Parse(userID) @@ -60,15 +63,17 @@ func AddUserToTeam(t *testing.T, c *api.ClientWithResponses, db *db.DB, teamID u SetUserID(userUUID). SetTeamID(teamID). SetIsDefault(false). - Save(t.Context()) + Save(ctx) require.NoError(t, err) + // Cleanup should use background context as test context may be canceled + //nolint:contextcheck t.Cleanup(func() { - db.Client.UsersTeams.DeleteOne(userTeam).Exec(t.Context()) + db.Client.UsersTeams.DeleteOne(userTeam).Exec(context.Background()) }) } -func RemoveUserFromTeam(t *testing.T, c *api.ClientWithResponses, db *db.DB, teamID uuid.UUID, userID string) { +func RemoveUserFromTeam(t *testing.T, ctx context.Context, c *api.ClientWithResponses, db *db.DB, teamID uuid.UUID, userID string) { t.Helper() userUUID, err := uuid.Parse(userID) @@ -76,7 +81,7 @@ func RemoveUserFromTeam(t *testing.T, c *api.ClientWithResponses, db *db.DB, tea _, err = db.Client.UsersTeams.Delete(). Where(usersteams.UserID(userUUID), usersteams.TeamID(teamID)). - Exec(t.Context()) + Exec(ctx) require.NoError(t, err, "failed to remove user from team") }