diff --git a/Dockerfile.mock-convox b/Dockerfile.mock-convox index bca253eb..fd6ec073 100644 --- a/Dockerfile.mock-convox +++ b/Dockerfile.mock-convox @@ -16,6 +16,7 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ # Copy only the source needed for mock-convox COPY cmd/mock-convox ./cmd/mock-convox +COPY internal/cmdenv ./internal/cmdenv COPY internal/logging ./internal/logging # Build mock binary with caches diff --git a/cmd/mock-convox/handlers_processes.go b/cmd/mock-convox/handlers_processes.go index e7b30035..ab5f23e6 100644 --- a/cmd/mock-convox/handlers_processes.go +++ b/cmd/mock-convox/handlers_processes.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/websocket" mclog "github.com/DocSpring/rack-gateway/cmd/mock-convox/logging" + "github.com/DocSpring/rack-gateway/internal/cmdenv" ) func getProcesses(w http.ResponseWriter, r *http.Request) { @@ -83,6 +84,13 @@ func serviceProcesses(w http.ResponseWriter, r *http.Request) { rawBody, ) + // A rack runs whatever command it gets. This one refuses a command the gateway didn't wrap (cmdenv), so the + // CLI E2E tests catch a command reaching the rack with every env var. + if _, _, ok := cmdenv.Parse(r.Header.Get("Command")); !ok { + http.Error(w, "mock rack: the gateway didn't wrap the command", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) writeJSON(w, map[string]interface{}{ @@ -115,17 +123,20 @@ func execProcess(w http.ResponseWriter, r *http.Request) { }() _ = conn.WriteMessage(websocket.TextMessage, []byte("Connected to mock exec for app="+app+" pid="+id+"\n")) - cmd := r.Header.Get("command") - if cmd == "" { - cmd = r.URL.Query().Get("command") + // The rack runs the command the gateway wrapped (cmdenv): this one prints the command and the env vars it gets. + // A command the gateway didn't wrap fails, so the CLI E2E tests catch it. + plan, cmd, ok := cmdenv.Parse(r.Header.Get("Command")) + if !ok { + _ = conn.WriteMessage(websocket.TextMessage, []byte("mock rack: the gateway didn't wrap the command\n")) + _ = conn.WriteMessage(websocket.TextMessage, []byte(execExitMarker+"1\n")) + return } - if cmd != "" { - _ = conn.WriteMessage(websocket.TextMessage, []byte("$ "+cmd+"\n")) - if strings.HasPrefix(cmd, "echo ") { - _ = conn.WriteMessage(websocket.TextMessage, []byte(strings.TrimPrefix(cmd, "echo ")+"\n")) - } else { - _ = conn.WriteMessage(websocket.TextMessage, []byte("(mock output)\n")) - } + _ = conn.WriteMessage(websocket.TextMessage, []byte("$ "+cmd+"\n")) + _ = conn.WriteMessage(websocket.TextMessage, []byte("env: "+plan.Describe()+"\n")) + if strings.HasPrefix(cmd, "echo ") { + _ = conn.WriteMessage(websocket.TextMessage, []byte(strings.TrimPrefix(cmd, "echo ")+"\n")) + } else { + _ = conn.WriteMessage(websocket.TextMessage, []byte("(mock output)\n")) } // A rack ends every exec stream with this marker and the exit code. The CLI strips the line, exits with the // code, and fails when the marker is missing. diff --git a/convox.yml b/convox.yml index eeca5e39..1cedbb76 100644 --- a/convox.yml +++ b/convox.yml @@ -30,7 +30,7 @@ services: # Pinned to an immutable release tag. scripts/bump-version.sh sets it to the new # version (:vX.Y.Z); the release workflow publishes that tag when the v* git tag is pushed. # :e327add is the v0.1.1 release (identical to :latest when this was pinned). - image: docker.io/docspringcom/rack-gateway:v0.1.25 + image: docker.io/docspringcom/rack-gateway:v0.1.26 command: ./scripts/start-gateway.sh environment: - PORT=8080 @@ -54,7 +54,7 @@ services: admin: # Uses same image as gateway service (kept in sync by scripts/bump-version.sh) - image: docker.io/docspringcom/rack-gateway:v0.1.25 + image: docker.io/docspringcom/rack-gateway:v0.1.26 command: echo "Use this service to run migrations, database admin tasks, etc." environment: - PORT=8080 diff --git a/docs/src/content/docs/concepts/audit-logging.mdx b/docs/src/content/docs/concepts/audit-logging.mdx index defee766..2c888f1f 100644 --- a/docs/src/content/docs/concepts/audit-logging.mdx +++ b/docs/src/content/docs/concepts/audit-logging.mdx @@ -329,6 +329,12 @@ recorded with it as `justification` in the details. The gateway can require one or in one app (`require_command_justification`): a command without one is refused with `400` and audited as a denial. +The env vars a command got (see [Env vars](/user-guide/cli/commands/#env-vars)) are recorded with it: `env_vars`, +the names it got (only the ones the release sets reached it), or `all_env_vars: true` for every env var except +`RACK_URL`; `env_read_only`, each read-only env var it got with the env var it took its value from; and +`write: true` when it ran with `--write`, with the real values. `command` is the command as it was sent, without the +`sh` wrapper the gateway adds. + A command run with a script (`--script`, see [Scripts](/user-guide/cli/commands/#scripts)) is recorded readably: `command` is the command as a person would run it locally (`rails runner check.rb`), `script` the script's text (redacted like a command line), `script_name` its file name and `script_sha256` the SHA-256 of what was sent. The diff --git a/docs/src/content/docs/configuration/environment-variables.mdx b/docs/src/content/docs/configuration/environment-variables.mdx index 0805cdae..dd4eb57e 100644 --- a/docs/src/content/docs/configuration/environment-variables.mdx +++ b/docs/src/content/docs/configuration/environment-variables.mdx @@ -173,11 +173,15 @@ An environment variable pins its setting: the web UI shows it read-only, and the - Numbers: whole numbers from 1 to the setting's maximum: 1440 (a day) for `session_timeout_minutes` and `mfa_step_up_window_minutes`, 365 for `mfa_trusted_device_ttl_days`, and 10080 (a week) for `deploy_approval_window_minutes` -- Lists: a JSON array of strings, or a comma-separated list (use the JSON form when a value contains a comma, - e.g. an approved deploy command). A quoted value such as `"A,B"` (what `jsonencode` writes for a string) is - refused: use `jsonencode` on a list. The env var lists (`protected_env_vars`, `secret_env_vars`, - `audit_visible_env_vars`) must hold valid env var names, here and when saved through the API. -- Maps (`service_image_patterns`): a JSON object of strings +- Lists: a JSON array of strings, or a comma-separated list (use the JSON form when a value contains a comma). A + quoted value such as `"A,B"` (what `jsonencode` writes for a string) is refused: use `jsonencode` on a list. The + env var lists (`protected_env_vars`, `secret_env_vars`, `audit_visible_env_vars`, `command_env_vars`) must hold + valid env var names, here and when saved through the API. `command_env_vars` keeps each name's case and can't + name `RACK_URL`. +- Maps (`service_image_patterns`, `command_env_read_only`): a JSON object of strings. `command_env_read_only` maps + env var names to env var names. +- `approved_deploy_commands`: a JSON array of objects, such as `[{"command": "bin/migrate", "write": true}]` (see + [Per-App Settings](/integrations/deploy-approvals/#per-app-settings)). A list of strings is refused. - Strings: the plain value, or a JSON string (`"main"`, as Terraform's `jsonencode` writes it). Other quoting is refused. `verify_git_commit_mode` must be `latest` or `branch`. @@ -226,12 +230,16 @@ RGW_APP_MY_APP_SETTING_PROTECTED_ENV_VARS=DATABASE_URL,SECRET_KEY_BASE RGW_APP_MY_APP_SETTING_AUDIT_VISIBLE_ENV_VARS=PORT,RAILS_ENV RGW_APP_MY_APP_SETTING_REQUIRE_COMMAND_JUSTIFICATION=true RGW_APP_MY_APP_SETTING_SERVICE_IMAGE_PATTERNS={"*":"registry\\.example\\.com/my-app:{{GIT_COMMIT}}"} +RGW_APP_MY_APP_SETTING_APPROVED_DEPLOY_COMMANDS=[{"command":"bin/migrate","write":true}] +RGW_APP_MY_APP_SETTING_COMMAND_ENV_VARS=PATH,HOME,RAILS_ENV,DATABASE_URL +RGW_APP_MY_APP_SETTING_COMMAND_ENV_READ_ONLY={"DATABASE_URL":"READ_ONLY_DATABASE_URL"} ``` See [Deploy Approvals](/integrations/deploy-approvals/) for common keys. `require_command_justification` makes every command run in the app (`run`, `exec`) carry a `--justification` (see [Justifications](/user-guide/cli/commands/#justifications)). `RGW_SETTING_REQUIRE_COMMAND_JUSTIFICATION` does the -same for every app, and an app can't opt out of it. +same for every app, and an app can't opt out of it. `command_env_vars` and `command_env_read_only` decide the env +vars of the app's commands (see [Env vars](/user-guide/cli/commands/#env-vars)). ## Audit Logging diff --git a/docs/src/content/docs/deployment/production-checklist.mdx b/docs/src/content/docs/deployment/production-checklist.mdx index f22a0053..c81d7e9b 100644 --- a/docs/src/content/docs/deployment/production-checklist.mdx +++ b/docs/src/content/docs/deployment/production-checklist.mdx @@ -208,7 +208,10 @@ Use this checklist before deploying Rack Gateway to production. Each item addres - Approval job names match - GitHub integration tested - `service_image_patterns` set for each app, pinning the image repository (approval-bound builds are refused without it) - - `approved_deploy_commands` lists the exact commands CI runs (an empty list allows none) + - `approved_deploy_commands` lists the exact commands CI runs (an empty list allows none), each with the env + vars it needs (`env`, `write`) + - `command_env_vars` lists the env vars every command gets, and `command_env_read_only` points the database URL + at a read-only user (see [Env vars](/user-guide/cli/commands/#env-vars)) - `vcs_repo` set to `owner/repo` for CircleCI auto-approval - [ ] **CI/CD tokens created** diff --git a/docs/src/content/docs/integrations/deploy-approvals/index.mdx b/docs/src/content/docs/integrations/deploy-approvals/index.mdx index 28b28f58..cbac37b2 100644 --- a/docs/src/content/docs/integrations/deploy-approvals/index.mdx +++ b/docs/src/content/docs/integrations/deploy-approvals/index.mdx @@ -99,7 +99,8 @@ Every approval is tied to a specific full (40-character) git commit SHA. An API - Build the archive it uploaded under that approval (one upload and one build per approval) - Send no `git-sha`, or one equal to the approved commit (the gateway always uses the approved commit, never the client's) - Reference only pre-built images, each tagged with the approved commit (`` or `-`, e.g. `-amd64`), with no digest (`@sha256:...`: the runtime would pull the digest whatever the tag says) and matching the app's `service_image_patterns`. Every image a service runs is checked (its `image` and its `initContainer.image`); resource image overrides and any other `image` key are refused, and `${...}` can't change an image or the manifest's structure (see [What the Manifest Check Covers](/integrations/deploy-approvals/workflow/#what-the-manifest-check-covers)) -- Run only commands listed in `approved_deploy_commands` (an empty list allows none) +- Run only commands listed in `approved_deploy_commands` (an empty list allows none), with the env vars their + entries give them `service_image_patterns` is required for approval-bound builds: the commit tag alone does not pin the image repository. @@ -229,7 +230,23 @@ Configure via UI or environment variables: | `circleci_approval_job_name` | CircleCI approval job name | | `circleci_auto_approve_on_approval` | Enable auto-approval (the gateway first checks the workflow's pipeline builds the approved commit of `vcs_repo`) | | `service_image_patterns` | **Required.** Map of service name (or `*`) to an anchored regex; `{{GIT_COMMIT}}` is replaced with the approved commit, e.g. `{"*": "docker\\.io/org/app:{{GIT_COMMIT}}-amd64"}` | -| `approved_deploy_commands` | Exact commands a token may run under an approval (e.g. migrations); empty allows none | +| `approved_deploy_commands` | The exact commands a token may run under an approval (e.g. migrations), each with the env vars it gets (see below); empty allows none | + +Each entry of `approved_deploy_commands` is an object: `command`, the exact command, and the env vars it gets on top +of the app's `command_env_vars` (see [Env vars](/user-guide/cli/commands/#env-vars)): `env`, a list of names; +`all_env_vars: true` for every env var except `RACK_URL`; and `write: true` for the real values of the app's +read-only env vars (`command_env_read_only`), so it can change data. + +```json +[ + { "command": "bin/pre_release_checks" }, + { "command": "bundle exec rake db:migrate", "write": true }, + { "command": "bundle exec rake stripe:prepare", "env": ["STRIPE_SECRET_KEY"], "write": true } +] +``` + +A command run under an approval can't ask for env vars itself (`--env`, `--all-env-vars` and `--write` are +refused): its entry decides. A command listed twice, an unknown field, or `env` with `all_env_vars` is refused.