Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions convox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.23
image: docker.io/docspringcom/rack-gateway:v0.1.24
command: ./scripts/start-gateway.sh
environment:
- PORT=8080
Expand All @@ -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.23
image: docker.io/docspringcom/rack-gateway:v0.1.24
command: echo "Use this service to run migrations, database admin tasks, etc."
environment:
- PORT=8080
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/concepts/audit-logging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,9 @@ the log can't show in full, and the refusal is audited as a denial. The rack-gat
starts a process, so it can say why; the Convox SDK only shows `websocket: bad handshake` for a refused exec.

A command's justification (`--justification`, or the deploy approval's message for a command run under one) is
recorded with it as `justification` in the details. An app can require one for every command
(`require_command_justification`): a command without one is refused with `400` and audited as a denial.
recorded with it as `justification` in the details. The gateway can require one for every command, in every app
or in one app (`require_command_justification`): a command without one is refused with `400` and audited as a
denial.

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ startup log lists them, and the settings page marks them and lets an admin clear
| `RGW_SETTING_DEPLOY_APPROVAL_WINDOW_MINUTES` | Approval window |
| `RGW_SETTING_DEPLOY_APPROVAL_REQUIRE_DIFFERENT_APPROVER` | Require a different approver |
| `RGW_SETTING_ALLOW_DESTRUCTIVE_ACTIONS` | Allow destructive actions |
| `RGW_SETTING_REQUIRE_COMMAND_JUSTIFICATION` | Require a justification for every command in every app |
| `RGW_SETTING_DEFAULT_CI_PROVIDER` | Default CI provider |
| `RGW_SETTING_DEFAULT_VCS_PROVIDER` | Default VCS provider |

Expand All @@ -229,7 +230,8 @@ RGW_APP_MY_APP_SETTING_SERVICE_IMAGE_PATTERNS={"*":"registry\\.example\\.com/my-

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)).
[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.

## Audit Logging

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/development/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ All under `/auth/mfa`:
- `GET /settings`
- `PUT /settings/mfa-configuration`, `DELETE /settings/mfa-configuration`
- `PUT /settings/allow-destructive-actions`, `DELETE /settings/allow-destructive-actions`
- `PUT /settings/require-command-justification`, `DELETE /settings/require-command-justification`
- `PUT /settings/vcs-and-ci-defaults`, `DELETE /settings/vcs-and-ci-defaults`
- `PUT /settings/deploy-approvals`, `DELETE /settings/deploy-approvals`
- `PUT /settings/session-configuration`, `DELETE /settings/session-configuration`
Expand Down
16 changes: 14 additions & 2 deletions docs/src/content/docs/operations/upgrades.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ A restore to before the upgrade loses the audit events written since; see

## Release Notes That Need Action

### Gateway-wide command justifications (v0.1.24)

`require_command_justification` is now a gateway-wide setting as well as a per-app one (the Settings page, or
`RGW_SETTING_REQUIRE_COMMAND_JUSTIFICATION=true`). With it on, every command in every app needs a justification,
whatever the app's own setting says.

1. **Turn it on once your users' CLIs are on v0.1.24.** An older CLI reads only the app's setting, so it doesn't
stop an `exec` without a justification before it starts: the gateway refuses it, and the CLI only shows
`websocket: bad handshake`. A `run` still shows the gateway's reason.
2. **`scripts/deploy.sh` gives its migration run a justification**, so deploys keep working with the setting on.

### Fresh MFA for every command (v0.1.19 to v0.1.22)

v0.1.19 asks for MFA on every `run` and `exec`, adds justifications and `--script`. Upgrade straight to v0.1.22:
Expand All @@ -224,8 +235,9 @@ without a terminal needs no separate PIN dialog.
command by its runner, which v0.1.21 changed: when only one side has that change, the audit log records the raw
`sh -c` command instead of the script's name, text and SHA-256.
3. **`require_command_justification` is off by default.** Turn it on per app (app settings page, the API, or
`RGW_APP_<APP>_SETTING_REQUIRE_COMMAND_JUSTIFICATION=true`) once your users' CLIs are on v0.1.19+. Commands run
under a deploy approval use the approval's message as their justification.
`RGW_APP_<APP>_SETTING_REQUIRE_COMMAND_JUSTIFICATION=true`) once your users' CLIs are on v0.1.19+, or for every
app from v0.1.24 once their CLIs are on v0.1.24 (see above). Commands run under a deploy approval use the
approval's message as their justification.
4. **To hear about commands in Slack, map `process.exec.start` and `process.start`** to a channel. The message
never includes the command or the script, and links to the audit entry.

Expand Down
7 changes: 4 additions & 3 deletions docs/src/content/docs/security/rbac/permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,10 @@ Reads and lists need none. Any other permission needs **always** unless it's lis
So these always need MFA on the request itself: running a command in an app (`convox:process:exec`, and
`convox:process:start` except `convox run`'s placeholder start, whose command comes as an exec), deleting apps, importing builds, creating, changing, locking and
deleting users, revoking sessions, creating, changing and deleting API tokens, approving, rejecting and extending deploy approvals, removing an MFA method, refreshing the rack TLS certificate,
changing protected env vars, privileged process options, and the settings that protect secrets or loosen deploy
checks (`mfa_configuration`, `allow_destructive_actions`, `deploy_approvals`, `vcs_ci_deploy`,
`protected_env_vars`, `secret_env_vars`, `github_verification`, `require_pr_for_branch`).
changing protected env vars, privileged process options, and the settings that protect secrets, loosen deploy
checks or require justifications (`mfa_configuration`, `allow_destructive_actions`, `deploy_approvals`,
`vcs_ci_deploy`, `protected_env_vars`, `secret_env_vars`, `github_verification`, `require_pr_for_branch`, and
`require_command_justification`, gateway-wide and per app).

API tokens can't verify MFA, so MFA levels don't apply to them; what a token may do is limited by its permissions and
its owner's role. The levels are defined in `internal/gateway/rbac/mfa_requirements.go`.
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/user-guide/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,10 @@ is running, just before the command runs (see [Running commands](/user-guide/cli
#### Justifications

`--justification` says why a command is run and what it does, in one line of 16 to 1000 characters. The gateway
records it with the command in the audit log, and the PIN dialog shows it. An app can require one for every
command (the `require_command_justification` setting): `run` and `exec` without one are then refused before
records it with the command in the audit log, and the PIN dialog shows it. The `require_command_justification`
setting requires one for every command, in every app (on the Settings page, or
`RGW_SETTING_REQUIRE_COMMAND_JUSTIFICATION=true`) or in one app (its own setting). Either one is enough, and an app
can't opt out of the gateway-wide setting. `run` and `exec` without a justification are then refused before
anything starts. A command run under a deploy approval is justified by the approval's message.

#### Scripts
Expand Down
9 changes: 8 additions & 1 deletion docs/src/content/docs/user-guide/web-ui/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ Set the defaults for app-level deploy approvals:

These defaults are used when an app does not specify its own provider settings.

### Command Justifications

Require a justification for every command run in every app (`run`, `exec`): each one must say why it's run and
what it does (see [Justifications](/user-guide/cli/commands/#justifications)). Apps can also require one on their
own; with this on, an app requires one whatever its own setting says. Changing it needs MFA on the request.

## App Settings (Per-App)

App-specific settings are **not** on this page. Go to **Apps → `{app}` → Settings** to configure:
Expand All @@ -66,7 +72,8 @@ App-specific settings are **not** on this page. Go to **Apps → `{app}` → Set
- Approved deploy commands
- Service image patterns
- Command justifications: whether every command run in the app (`run`, `exec`) must say why it's run (see
[Justifications](/user-guide/cli/commands/#justifications))
[Justifications](/user-guide/cli/commands/#justifications)). The gateway-wide setting above requires one in
every app, and the app's card says so when it's on

## Related

Expand Down
6 changes: 3 additions & 3 deletions internal/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ placeholder start and the waits go without a proof, and the proof is collected j
detached run's start), with the terminal taken out of the raw mode Convox put it in, so a TOTP code or a WebAuthn
challenge doesn't expire while a slow process starts. The PIN dialog summary names the app, rack, command and
justification (`commandPrompt`). `--justification` travels in `audit.JustificationHeader` (percent-encoded), which
the gateway records and doesn't forward. When the app's `require_command_justification` is on, the CLI refuses a
command without one before anything starts (it reads the app's settings; API tokens can't, and rely on the
gateway's refusal).
the gateway records and doesn't forward. When `require_command_justification` is on (gateway-wide or the app's),
the CLI refuses a command without one before anything starts (it reads the global and app settings; API tokens
can't, and rely on the gateway's refusal).

`--script FILE` (`convox_process_command.go`) sends a local script with the command words: `scriptrun.Build` wraps
them in a fixed `sh` runner that decodes the base64 script into a temp directory and runs the words with the
Expand Down
27 changes: 16 additions & 11 deletions internal/cli/convox_env_protected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,30 @@ import (
)

// envChangeGateway is a fake gateway whose MFA status reports mfaMethods (a TOTP method by default) and a step-up
// window that is still open, that starts WebAuthn assertions, and whose apps have appSettings (none by default). It
// records the headers of every rack-proxy request.
// window that is still open, that starts WebAuthn assertions, and that has globalSettings and appSettings (none by
// default). It records the headers of every rack-proxy request.
type envChangeGateway struct {
mu sync.Mutex
proxied []http.Header
appSettings string
mfaMethods []MFAMethodResponse
mu sync.Mutex
proxied []http.Header
globalSettings string
appSettings string
mfaMethods []MFAMethodResponse
}

func (g *envChangeGateway) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasPrefix(r.URL.Path, "/api/v1/apps/") && strings.HasSuffix(r.URL.Path, "/settings") {
if r.URL.Path == "/api/v1/settings" ||
strings.HasPrefix(r.URL.Path, "/api/v1/apps/") && strings.HasSuffix(r.URL.Path, "/settings") {
g.mu.Lock()
appSettings := g.appSettings
values := g.appSettings
if r.URL.Path == "/api/v1/settings" {
values = g.globalSettings
}
g.mu.Unlock()
if appSettings == "" {
appSettings = "{}"
if values == "" {
values = "{}"
}
_, _ = w.Write([]byte(appSettings))
_, _ = w.Write([]byte(values))
return
}
if r.URL.Path == "/api/v1/auth/mfa/webauthn/assertion/start" {
Expand Down
25 changes: 15 additions & 10 deletions internal/cli/convox_process_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ func commandJustification(cmd *cobra.Command, app string) (string, error) {
return "", nil
}

// appRequiresJustification reads the app's require_command_justification setting. API tokens can't read settings;
// the gateway still refuses their commands without one (deploy approvals justify theirs with the approval).
// appRequiresJustification reads require_command_justification: the gateway-wide setting, then the app's. Either
// one requires a justification. API tokens can't read settings; the gateway still refuses their commands without
// one (deploy approvals justify theirs with the approval).
func appRequiresJustification(cmd *cobra.Command, app string) (bool, error) {
if apiTokenOverride() != "" {
return false, nil
Expand All @@ -60,15 +61,19 @@ func appRequiresJustification(cmd *cobra.Command, app string) (bool, error) {
if err != nil {
return false, err
}
var appSettings map[string]struct {
Value interface{} `json:"value"`
}
path := "/apps/" + url.PathEscape(app) + "/settings"
if err := gatewayRequest(cmd, rack, http.MethodGet, path, nil, &appSettings); err != nil {
return false, fmt.Errorf("failed to read whether app %s requires a justification: %w", displayText(app), err)
for _, path := range []string{"/settings", "/apps/" + url.PathEscape(app) + "/settings"} {
var values map[string]struct {
Value interface{} `json:"value"`
}
if err := gatewayRequest(cmd, rack, http.MethodGet, path, nil, &values); err != nil {
return false, fmt.Errorf("failed to read whether app %s requires a justification: %w",
displayText(app), err)
}
if required, _ := values[settings.KeyRequireCommandJustification].Value.(bool); required {
return true, nil
}
}
required, _ := appSettings[settings.KeyRequireCommandJustification].Value.(bool)
return required, nil
return false, nil
}

// commandPrompt describes a command for the person approving it with MFA: where it runs, the command (a script's
Expand Down
27 changes: 18 additions & 9 deletions internal/cli/convox_process_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,26 @@ func processCobraCommand(t *testing.T, cmd *cobra.Command, flags map[string]stri
return cmd
}

// Without --justification, a command in an app that requires one is refused before anything is sent to the rack
// (the gateway's refusal of an exec would only show as "websocket: bad handshake").
// Without --justification, a command in an app that requires one (its own setting, or the gateway-wide one) is
// refused before anything is sent to the rack (the gateway's refusal of an exec would only show as
// "websocket: bad handshake").
func TestProcessCommandRefusedWithoutAJustificationTheAppRequires(t *testing.T) {
gateway := newEnvChangeGateway(t)
gateway.appSettings = `{"require_command_justification":{"value":true}}`
for _, cmd := range []*cobra.Command{ExecCommand(), RunCommand()} {
cmd = processCobraCommand(t, cmd, nil)
_, _, err := setupProcessCommand(cmd, []string{"web", "rails", "runner", "puts 1"}, cmd.Name())
require.ErrorContains(t, err, "app myapp requires a justification for every command")
const required = `{"require_command_justification":{"value":true}}`
for name, set := range map[string]func(*envChangeGateway){
"app setting": func(g *envChangeGateway) { g.appSettings = required },
"gateway-wide": func(g *envChangeGateway) { g.globalSettings = required },
} {
t.Run(name, func(t *testing.T) {
gateway := newEnvChangeGateway(t)
set(gateway)
for _, cmd := range []*cobra.Command{ExecCommand(), RunCommand()} {
cmd = processCobraCommand(t, cmd, nil)
_, _, err := setupProcessCommand(cmd, []string{"web", "rails", "runner", "puts 1"}, cmd.Name())
require.ErrorContains(t, err, "app myapp requires a justification for every command")
}
require.Empty(t, gateway.requests())
})
}
require.Empty(t, gateway.requests())
}

// A justification that can't say why the command is run is refused. A good one goes with each of the command's
Expand Down
5 changes: 3 additions & 2 deletions internal/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ Request forwarding with WebSocket support:
no proof; the exec that follows carries the command's proof
- Command justifications (`proxy/command_justification.go`): an exec's or process start's
`X-Command-Justification` (percent-encoded, checked by `audit.CheckJustification`, never forwarded), or the
deploy approval's message, goes on the request context and into its audit entries' details. An app with
`require_command_justification` refuses a command without one, the placeholder start included
deploy approval's message, goes on the request context and into its audit entries' details. With
`require_command_justification` on (gateway-wide, or the app's own; `GetRequireCommandJustification` reads both),
a command without one is refused, the placeholder start included
- MFA settings are read when needed (`settings.Service.GetMFASettings`, one query; the middleware reads them once
per request), never kept from startup, so a change applies at once on every instance
- Client IPs: use `c.ClientIP()` in gin handlers and `netutil.ClientIP(r)` elsewhere. Both trust
Expand Down
12 changes: 12 additions & 0 deletions internal/gateway/handlers/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ func (h *SettingsHandler) UpdateGlobalAllowDestructiveActions(c *gin.Context) {
h.updateGlobalSettingsWithKeys(c, allowedKeys)
}

// UpdateGlobalRequireCommandJustification updates whether every command in every app needs a justification.
func (h *SettingsHandler) UpdateGlobalRequireCommandJustification(c *gin.Context) {
allowedKeys := settings.GlobalSettingGroupKeyStrings(settings.GlobalSettingGroupCommandJustification)
h.updateGlobalSettingsWithKeys(c, allowedKeys)
}

// UpdateGlobalVCSAndCIDefaults updates VCS and CI default settings.
func (h *SettingsHandler) UpdateGlobalVCSAndCIDefaults(c *gin.Context) {
allowedKeys := settings.GlobalSettingGroupKeyStrings(settings.GlobalSettingGroupVCSAndCIDefaults)
Expand Down Expand Up @@ -176,6 +182,12 @@ func (h *SettingsHandler) DeleteGlobalAllowDestructiveActions(c *gin.Context) {
h.deleteGlobalSettingsWithKeys(c, allowedKeys)
}

// DeleteGlobalRequireCommandJustification deletes the gateway-wide require_command_justification.
func (h *SettingsHandler) DeleteGlobalRequireCommandJustification(c *gin.Context) {
allowedKeys := settings.GlobalSettingGroupKeyStrings(settings.GlobalSettingGroupCommandJustification)
h.deleteGlobalSettingsWithKeys(c, allowedKeys)
}

// DeleteGlobalVCSAndCIDefaults deletes VCS and CI default settings.
func (h *SettingsHandler) DeleteGlobalVCSAndCIDefaults(c *gin.Context) {
allowedKeys := settings.GlobalSettingGroupKeyStrings(settings.GlobalSettingGroupVCSAndCIDefaults)
Expand Down
7 changes: 4 additions & 3 deletions internal/gateway/proxy/command_justification.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ var errJustificationRequired = errors.New("this app requires a justification for

// checkCommandJustification finds the justification of the command r runs (an exec's, or a process start's) and
// puts it on r, so the request's audit entries record it. A command under a deploy approval is justified by the
// approval's message. In an app that requires one (require_command_justification), a command without one is
// refused: `convox run`'s placeholder start too, so an attached run without one is refused before its process
// starts, with a reason the CLI can show (it can't show why an exec's WebSocket handshake was refused).
// approval's message. In an app that requires one (require_command_justification, the app's own or the
// gateway-wide one), a command without one is refused: `convox run`'s placeholder start too, so an attached run
// without one is refused before its process starts, with a reason the CLI can show (it can't show why an exec's
// WebSocket handshake was refused).
func (h *Handler) checkCommandJustification(r *http.Request, tracker *deployApprovalTracker) error {
if _, ok := audit.RequestCommand(r); !ok {
return nil
Expand Down
Loading