From d70da8d7ea7d361c1a34ea0d4b929bf105c0d6fa Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Tue, 13 Oct 2026 15:07:39 +1300 Subject: [PATCH] fix: CLI rack status, trusted device TTL, approval denial reasons, admin alerts, dead CLI commands - `rack-gateway rack` shows the rack commands would use (--rack, then RACK_GATEWAY_RACK, then the current rack), not always the config's current rack (#133) - The MFA status response carries the trusted device TTL, and the web UI's "Trust this device" labels use it instead of a hardcoded 30 days (#134) - An API token refused by deploy_with_approval gets a 403 that says why, from its own newest request for the app (#136) - Security alerts go to the current unlocked admins, looked up when sent, instead of the admins at startup (#137) - Remove CLI commands the gateway always refuses: resources, certs, registries, instances keyroll/terminate and apps cancel (#138) Fixes #133, fixes #134, fixes #136, fixes #137, fixes #138 Co-Authored-By: Claude Opus 5.5 --- .../deploy-approvals/workflow.mdx | 13 +- docs/src/content/docs/integrations/email.mdx | 3 +- .../docs/security/rbac/permissions.mdx | 6 +- .../content/docs/user-guide/cli/commands.mdx | 9 +- .../docs/user-guide/cli/multi-rack.mdx | 4 +- .../docs/user-guide/mfa/trusted-devices.mdx | 4 +- internal/cli/cli_rack.go | 7 +- internal/cli/cli_test.go | 37 +++ internal/cli/convox_apps.go | 23 -- internal/cli/convox_logs.go | 50 --- internal/cli/convox_resources.go | 288 ------------------ internal/cli/rack.go | 33 +- internal/cli/root.go | 5 - internal/gateway/app/setup.go | 27 +- .../gateway/handlers/auth_mfa_management.go | 1 + internal/gateway/handlers/dto.go | 2 + .../gateway/openapi/generated/swagger.json | 5 + internal/gateway/proxy/approval_denial.go | 66 ++++ .../gateway/proxy/approval_denial_test.go | 77 +++++ internal/gateway/proxy/token_permissions.go | 29 +- internal/gateway/security/notifier.go | 28 +- internal/gateway/security/notifier_test.go | 28 ++ web/src/api/openapi.json | 5 + .../api/schemas/handlersMFAStatusResponse.ts | 2 + web/src/api/types.generated.ts | 2 + .../account-security/enrollment-dialog.tsx | 3 +- .../components/mfa-verification-form.test.tsx | 8 +- web/src/components/mfa-verification-form.tsx | 1 + .../mfa-verification-form/form-sections.tsx | 9 +- web/src/lib/time.test.ts | 14 + web/src/lib/time.ts | 9 + 31 files changed, 342 insertions(+), 456 deletions(-) delete mode 100644 internal/cli/convox_resources.go create mode 100644 internal/gateway/proxy/approval_denial.go create mode 100644 internal/gateway/proxy/approval_denial_test.go create mode 100644 internal/gateway/security/notifier_test.go create mode 100644 web/src/lib/time.test.ts diff --git a/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx b/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx index 14ee8571..9b44f8a0 100644 --- a/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx +++ b/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx @@ -409,14 +409,15 @@ or the app's `vcs_repo` is missing. No request or `create` entry exists for a re ``` -Error: You don't have permission to promote releases. +Error: You don't have permission to promote releases: this API token has no usable deploy approval for myapp: +its newest request, 3f2b9c1e-8d4a-4b6e-9f1a-2c7d5e8a1b0f (commit 0123456), was approved, but its approval +window ended at 2026-10-13T11:59:00Z ``` -A token that relies on `deploy_with_approval` gets the same 403 as any other missing permission when it has no -approved, unexpired request for the app (`permission denied` for uploads and builds). The gateway doesn't say -which, so it can't be used to probe approvals. With `LOG_LEVEL=debug` and `DEBUG_TOPICS=deploy.approval`, the -gateway logs the reason (`deploy approval expired`, `deploy approval status check failed: expected=approved -actual=pending`, or not found). +A token that relies on `deploy_with_approval` gets a 403 that says why, from its own newest request for the app: +it has none, or that request is waiting for approval, was rejected, has already been deployed, expired, or is +approved but doesn't cover this upload, build, release or command. The token can already read its own requests, so +the message reveals nothing new. **Causes:** - No request was created for this app and token, or it was rejected diff --git a/docs/src/content/docs/integrations/email.mdx b/docs/src/content/docs/integrations/email.mdx index 429b45d0..8ccf1c40 100644 --- a/docs/src/content/docs/integrations/email.mdx +++ b/docs/src/content/docs/integrations/email.mdx @@ -109,7 +109,8 @@ Sent to the address that tried when a login fails after Google (for example an a #### Rate Limit Exceeded -Sent when a request is rate limited: to the signed-in user (if any), and to every admin. +Sent when a request is rate limited: to the signed-in user (if any), and to every admin who isn't locked +(looked up when it's sent). **To User:** | Field | Content | diff --git a/docs/src/content/docs/security/rbac/permissions.mdx b/docs/src/content/docs/security/rbac/permissions.mdx index 14217f23..fda3d2c1 100644 --- a/docs/src/content/docs/security/rbac/permissions.mdx +++ b/docs/src/content/docs/security/rbac/permissions.mdx @@ -56,9 +56,9 @@ Resources that map to Convox API operations: | `secret` | Secret env var values (`convox:secret:read` reveals them) | The gateway forwards only the rack API routes it has a permission for (see -[Rack API Routes](#rack-api-routes)). Requests to any other route are refused with `404` and logged as denied, so -`certs`, `registries`, `resources`, `instances keyroll`, `instances terminate` and `apps cancel` don't work -through the gateway, whatever the caller's role. +[Rack API Routes](#rack-api-routes)). Requests to any other route are refused with `404` and logged as denied, +whatever the caller's role, so Convox's certificates, registries, resources, instance keyroll and terminate, and +canceling an app update aren't available through the gateway (the `rack-gateway` CLI has no commands for them). ### Gateway Resources diff --git a/docs/src/content/docs/user-guide/cli/commands.mdx b/docs/src/content/docs/user-guide/cli/commands.mdx index 1022a1b9..c0f68292 100644 --- a/docs/src/content/docs/user-guide/cli/commands.mdx +++ b/docs/src/content/docs/user-guide/cli/commands.mdx @@ -86,7 +86,7 @@ rack-gateway test-auth mfa --mfa-method webauthn # Verify a specific method ### rack -Show the current rack and your login. Its subcommands (`info`, `logs`, `params`, `ps`, `releases`, `scale`, +Show the current rack (or the one `--rack` or `RACK_GATEWAY_RACK` selects) and your login. Its subcommands (`info`, `logs`, `params`, `ps`, `releases`, `scale`, `update`) act on the rack itself. ```bash @@ -131,9 +131,10 @@ rack-gateway switch staging ## Convox Commands -These commands are proxied to the Convox rack API. They work the same as the native `convox` CLI commands, for the -rack API routes the gateway forwards. `certs`, `registries`, `resources`, `instances keyroll`, `instances terminate` -and `apps cancel` are refused (see [Permissions](/security/rbac/permissions/#rack-api-routes)). +These commands are proxied to the Convox rack API and work the same as the native `convox` CLI commands. The CLI +only has commands for the rack API routes the gateway forwards (see +[Permissions](/security/rbac/permissions/#rack-api-routes)), so Convox commands such as `certs`, `registries`, +`resources`, `instances keyroll`, `instances terminate` and `apps cancel` aren't available. Commands that take `-a, --app` find the app from the flag, then `CONVOX_APP`, then a `.convox/app` file in the current directory or a parent, then the current directory's name. diff --git a/docs/src/content/docs/user-guide/cli/multi-rack.mdx b/docs/src/content/docs/user-guide/cli/multi-rack.mdx index 289ad249..f91ef5ef 100644 --- a/docs/src/content/docs/user-guide/cli/multi-rack.mdx +++ b/docs/src/content/docs/user-guide/cli/multi-rack.mdx @@ -220,8 +220,8 @@ rack-gateway login production It's normal to have different users or roles on different racks. Each rack maintains its own session: -`rack-gateway rack` shows who you're logged in as on the current rack (it always shows the current rack, whatever -`--rack` says); `switch` to another rack to see its login. +`rack-gateway rack` shows who you're logged in as on the current rack; `rack-gateway rack -r staging` shows your +login on staging. ## Next Steps diff --git a/docs/src/content/docs/user-guide/mfa/trusted-devices.mdx b/docs/src/content/docs/user-guide/mfa/trusted-devices.mdx index 74a20c14..9526b3f1 100644 --- a/docs/src/content/docs/user-guide/mfa/trusted-devices.mdx +++ b/docs/src/content/docs/user-guide/mfa/trusted-devices.mdx @@ -24,7 +24,7 @@ You can trust a device in two ways: 1. Complete MFA verification -2. Check **Trust this device for 30 days** +2. Check **Trust this device for N days** (the configured TTL, 30 by default) 3. Finish sign-in @@ -38,8 +38,6 @@ You can trust a device in two ways: -The checkbox and the button always say 30 days, but the trust lasts as long as the configured TTL. - ## Managing Trusted Devices The **Trusted Devices** table shows: diff --git a/internal/cli/cli_rack.go b/internal/cli/cli_rack.go index 33c223be..4cd35068 100644 --- a/internal/cli/cli_rack.go +++ b/internal/cli/cli_rack.go @@ -12,9 +12,10 @@ import ( func RackCommand() *cobra.Command { cmd := &cobra.Command{ Use: "rack", - Short: "Show current rack configuration", - Long: "Display the current rack name, gateway URL, and login status from local configuration (no API call)", - Args: cobra.NoArgs, + Short: "Show the selected rack's configuration", + Long: "Display the selected rack's name, gateway URL, and login status from local configuration (no API " + + "call). That's the current rack unless --rack or RACK_GATEWAY_RACK selects another.", + Args: cobra.NoArgs, RunE: SilenceOnError(func(_ *cobra.Command, _ []string) error { status, err := ResolveRackStatus(time.Now()) if err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 48767ef9..abf0b80f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -211,6 +211,7 @@ func TestSetCurrentRackCreatesDirectory(t *testing.T) { func TestResolveRackStatusPrefersConfig(t *testing.T) { tmpDir := t.TempDir() ConfigPath = tmpDir + t.Setenv("RACK_GATEWAY_RACK", "") t.Setenv("RACK_GATEWAY_URL", "") t.Setenv("RACK_GATEWAY_API_TOKEN", "") @@ -233,6 +234,42 @@ func TestResolveRackStatusPrefersConfig(t *testing.T) { assert.Contains(t, strings.Join(status.StatusLines, " "), "Logged in as user@example.com") } +// `rack` shows the rack commands would use: --rack, then RACK_GATEWAY_RACK, then the current rack. +func TestResolveRackStatusFollowsTheSelectedRack(t *testing.T) { + gateway := func(host string) GatewayConfig { + return GatewayConfig{ + URL: "https://" + host, + Token: "abc123", + Email: "user@example.com", + ExpiresAt: time.Now().Add(time.Hour), + } + } + withCLIConfig(t, Config{Current: "production", Gateways: map[string]GatewayConfig{ + "production": gateway("gateway-production.example.com"), + "staging": gateway("gateway-staging.example.com"), + }}) + t.Setenv("RACK_GATEWAY_RACK", "") + + status, err := ResolveRackStatus(time.Now()) + require.NoError(t, err) + assert.Equal(t, "production", status.Rack) + + t.Setenv("RACK_GATEWAY_RACK", "staging") + status, err = ResolveRackStatus(time.Now()) + require.NoError(t, err) + assert.Equal(t, "staging", status.Rack) + assert.Equal(t, "https://gateway-staging.example.com", status.GatewayURL) + + RackFlag = "production" + status, err = ResolveRackStatus(time.Now()) + require.NoError(t, err) + assert.Equal(t, "production", status.Rack) + + RackFlag = "eu" + _, err = ResolveRackStatus(time.Now()) + require.ErrorContains(t, err, "rack eu not configured") +} + func TestResolveRackStatusFallsBackToEnv(t *testing.T) { ConfigPath = t.TempDir() t.Setenv("RACK_GATEWAY_RACK", "") diff --git a/internal/cli/convox_apps.go b/internal/cli/convox_apps.go index 2afe129a..8424c991 100644 --- a/internal/cli/convox_apps.go +++ b/internal/cli/convox_apps.go @@ -30,7 +30,6 @@ func AppsCommand() *cobra.Command { cmd.AddCommand(appsCreateCommand()) cmd.AddCommand(appsDeleteCommand()) cmd.AddCommand(appsInfoCommand()) - cmd.AddCommand(appsCancelCommand()) cmd.AddCommand(appsLockCommand()) cmd.AddCommand(appsUnlockCommand()) cmd.AddCommand(appsParamsCommand()) @@ -112,28 +111,6 @@ func appsInfoCommand() *cobra.Command { return cmd } -func appsCancelCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "cancel ", - Short: "cancel an app update", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "apps cancel") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.AppsCancel(client, ctx) - }), - } - - return cmd -} - func appsLockCommand() *cobra.Command { cmd := &cobra.Command{ Use: "lock ", diff --git a/internal/cli/convox_logs.go b/internal/cli/convox_logs.go index 9c329b20..25f6d52a 100644 --- a/internal/cli/convox_logs.go +++ b/internal/cli/convox_logs.go @@ -56,55 +56,5 @@ func InstancesCommand() *cobra.Command { }), } - // Add subcommands - cmd.AddCommand(instancesKeyrollCommand()) - cmd.AddCommand(instancesTerminateCommand()) - - return cmd -} - -func instancesKeyrollCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "keyroll", - Short: "roll SSH keys", - Args: cobra.NoArgs, - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "instances keyroll") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.InstancesKeyroll(client, ctx) - }), - } - - cmd.Flags().Bool("wait", false, "wait for keyroll to complete") - - return cmd -} - -func instancesTerminateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "terminate ", - Short: "terminate an instance", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "instances terminate") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.InstancesTerminate(client, ctx) - }), - } - return cmd } diff --git a/internal/cli/convox_resources.go b/internal/cli/convox_resources.go deleted file mode 100644 index 761c7c31..00000000 --- a/internal/cli/convox_resources.go +++ /dev/null @@ -1,288 +0,0 @@ -package cli - -import ( - "github.com/convox/convox/pkg/cli" - "github.com/spf13/cobra" -) - -// ResourcesCommand creates the resources command -func ResourcesCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "resources", - Short: "list resources", - Args: cobra.NoArgs, - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - if err := resolveAppFlag(cobraCmd); err != nil { - return err - } - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "resources") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.Resources(client, ctx) - }), - } - - cmd.Flags().StringP("app", "a", "", appFlagHelp) - - // Add subcommands - cmd.AddCommand(resourcesInfoCommand()) - cmd.AddCommand(resourcesURLCommand()) - cmd.AddCommand(resourcesExportCommand()) - - return cmd -} - -func resourcesExportCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "export ", - Short: "export a resource", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - if err := resolveAppFlag(cobraCmd); err != nil { - return err - } - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "resources export") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.ResourcesExport(client, ctx) - }), - } - - cmd.Flags().StringP("app", "a", "", appFlagHelp) - cmd.Flags().StringP("file", "f", "", "output file") - - return cmd -} - -func resourcesInfoCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "info ", - Short: "get information about a resource", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - if err := resolveAppFlag(cobraCmd); err != nil { - return err - } - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "resources info") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.ResourcesInfo(client, ctx) - }), - } - - cmd.Flags().StringP("app", "a", "", appFlagHelp) - - return cmd -} - -func resourcesURLCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "url ", - Short: "get resource URL", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - if err := resolveAppFlag(cobraCmd); err != nil { - return err - } - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "resources url") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.ResourcesUrl(client, ctx) - }), - } - - cmd.Flags().StringP("app", "a", "", appFlagHelp) - - return cmd -} - -// CertificatesCommand creates the certificates command -func CertificatesCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "certs", - Short: "list certificates", - Args: cobra.NoArgs, - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "certs") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.Certs(client, ctx) - }), - } - - // Add subcommands - cmd.AddCommand(certsDeleteCommand()) - cmd.AddCommand(certsGenerateCommand()) - cmd.AddCommand(certsImportCommand()) - - return cmd -} - -func certsDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete ", - Short: "delete a certificate", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "certs delete") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.CertsDelete(client, ctx) - }), - } - - return cmd -} - -func certsGenerateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "generate ...", - Short: "generate a certificate", - Args: cobra.MinimumNArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "certs generate") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.CertsGenerate(client, ctx) - }), - } - - return cmd -} - -func certsImportCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "import ", - Short: "import a certificate", - Args: cobra.ExactArgs(2), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "certs import") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.CertsImport(client, ctx) - }), - } - - cmd.Flags().String("chain", "", "certificate chain") - - return cmd -} - -// RegistriesCommand creates the registries command -func RegistriesCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "registries", - Short: "list registries", - Args: cobra.NoArgs, - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "registries") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.Registries(client, ctx) - }), - } - - // Add subcommands - cmd.AddCommand(registriesAddCommand()) - cmd.AddCommand(registriesRemoveCommand()) - - return cmd -} - -func registriesAddCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "add ", - Short: "add a registry", - Args: cobra.ExactArgs(3), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "registries add") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.RegistriesAdd(client, ctx) - }), - } - - return cmd -} - -func registriesRemoveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "remove ", - Short: "remove a registry", - Args: cobra.ExactArgs(1), - RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error { - mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "registries remove") - if err != nil { - return err - } - - client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth) - if err != nil { - return err - } - return cli.RegistriesRemove(client, ctx) - }), - } - - return cmd -} diff --git a/internal/cli/rack.go b/internal/cli/rack.go index 2755f741..5580a9b5 100644 --- a/internal/cli/rack.go +++ b/internal/cli/rack.go @@ -7,6 +7,9 @@ import ( "time" ) +// rackFromEnvironment is the selected rack when RACK_GATEWAY_URL names the gateway and no rack is named. +const rackFromEnvironment = "(from environment)" + // SelectedRack determines which rack to use based on flags, env vars, or config func SelectedRack() (string, error) { if RackFlag != "" { @@ -19,7 +22,7 @@ func SelectedRack() (string, error) { if label := strings.TrimSpace(os.Getenv("RACK_GATEWAY_RACK")); label != "" { return label, nil } - return "(from environment)", nil + return rackFromEnvironment, nil } rack, err := GetCurrentRack() if err != nil { @@ -49,23 +52,27 @@ func NormalizeGatewayURL(raw string) (string, error) { return trimmed, nil } -// ResolveRackStatus returns detailed status information about the current rack +// ResolveRackStatus describes, from local configuration, the rack and gateway commands would use: RACK_GATEWAY_URL +// with RACK_GATEWAY_API_TOKEN, or else the selected rack (--rack, RACK_GATEWAY_RACK or the current rack). func ResolveRackStatus(now time.Time) (*RackStatus, error) { - if status, err := resolveStatusFromConfig(now); status != nil || err != nil { - return status, err + if strings.TrimSpace(os.Getenv("RACK_GATEWAY_URL")) != "" && + strings.TrimSpace(os.Getenv("RACK_GATEWAY_API_TOKEN")) != "" { + return resolveStatusFromEnv() } - return resolveStatusFromEnv() -} - -func resolveStatusFromConfig(now time.Time) (*RackStatus, error) { - cfg, exists, err := LoadConfig() - if err != nil || !exists { + rack, err := SelectedRack() + if err != nil { return nil, err } + if rack == rackFromEnvironment { + return resolveStatusFromEnv() + } + return resolveStatusFromConfig(rack, now) +} - rack := strings.TrimSpace(cfg.Current) - if rack == "" { - return nil, nil +func resolveStatusFromConfig(rack string, now time.Time) (*RackStatus, error) { + cfg, _, err := LoadConfig() + if err != nil { + return nil, err } gateway, ok := cfg.Gateways[rack] diff --git a/internal/cli/root.go b/internal/cli/root.go index 0cb0f7b2..3b665c2d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -83,11 +83,6 @@ Rack management: LogsCommand(), RackCommand(), InstancesCommand(), - - // Resources - ResourcesCommand(), - CertificatesCommand(), - RegistriesCommand(), ) return rootCmd.Execute() diff --git a/internal/gateway/app/setup.go b/internal/gateway/app/setup.go index 7520fb5b..f91a719e 100644 --- a/internal/gateway/app/setup.go +++ b/internal/gateway/app/setup.go @@ -85,12 +85,11 @@ func (a *App) initializeServices() error { return err } - adminEmails := a.collectAdminEmails() a.configureAuditEnqueuer(auditLogger) if err := a.startJobsWorker(); err != nil { return err } - a.initSecurityNotifier(auditLogger, adminEmails) + a.initSecurityNotifier(auditLogger) rackName, rackAlias := a.deriveRackIdentity() a.initProxyHandler(auditLogger, rackName, rackAlias) @@ -323,26 +322,6 @@ func (a *App) initMFAService(cfg *mfaRuntimeConfig) error { return nil } -func (a *App) collectAdminEmails() []string { - users, err := a.Database.ListUsers() - if err != nil { - return nil - } - var emails []string - for _, user := range users { - if strings.TrimSpace(user.Email) == "" { - continue - } - for _, role := range user.Roles { - if role == "admin" { - emails = append(emails, user.Email) - break - } - } - } - return emails -} - func (a *App) configureAuditEnqueuer(logger *audit.Logger) { logger.SetAuditEventEnqueuer(jobs.NewAuditEventEnqueuer(a.JobsClient)) } @@ -355,8 +334,8 @@ func (a *App) startJobsWorker() error { return nil } -func (a *App) initSecurityNotifier(logger *audit.Logger, adminEmails []string) { - a.SecurityNotifier = security.NewNotifier(a.EmailSender, logger, a.Database, adminEmails, a.JobsClient) +func (a *App) initSecurityNotifier(logger *audit.Logger) { + a.SecurityNotifier = security.NewNotifier(a.EmailSender, logger, a.Database, a.JobsClient) } func (a *App) deriveRackIdentity() (string, string) { diff --git a/internal/gateway/handlers/auth_mfa_management.go b/internal/gateway/handlers/auth_mfa_management.go index 67dba412..a5ef9980 100644 --- a/internal/gateway/handlers/auth_mfa_management.go +++ b/internal/gateway/handlers/auth_mfa_management.go @@ -102,6 +102,7 @@ func (h *AuthHandler) GetMFAStatus(c *gin.Context) { RecentStepUpExpiresAt: recentExpires, PreferredMethod: ctx.userRecord.PreferredMFAMethod, WebAuthnAvailable: h.mfaService.IsWebAuthnConfigured(), + TrustedDeviceTTLDays: int(current.TrustedDeviceTTL() / (24 * time.Hour)), } c.JSON(http.StatusOK, response) } diff --git a/internal/gateway/handlers/dto.go b/internal/gateway/handlers/dto.go index ea0c3a53..17382c03 100644 --- a/internal/gateway/handlers/dto.go +++ b/internal/gateway/handlers/dto.go @@ -135,6 +135,8 @@ type MFAStatusResponse struct { RecentStepUpExpiresAt *time.Time `json:"recent_step_up_expires_at,omitempty"` PreferredMethod *string `json:"preferred_method,omitempty"` WebAuthnAvailable bool `json:"webauthn_available" validate:"required"` + // TrustedDeviceTTLDays is how long a trusted device skips the login MFA challenge. + TrustedDeviceTTLDays int `json:"trusted_device_ttl_days" validate:"required"` } // StatusResponse is returned for simple acknowledgment payloads. diff --git a/internal/gateway/openapi/generated/swagger.json b/internal/gateway/openapi/generated/swagger.json index 17298ce6..d4c0bbb9 100644 --- a/internal/gateway/openapi/generated/swagger.json +++ b/internal/gateway/openapi/generated/swagger.json @@ -5422,6 +5422,7 @@ "enrolled", "methods", "required", + "trusted_device_ttl_days", "trusted_devices", "webauthn_available" ], @@ -5447,6 +5448,10 @@ "required": { "type": "boolean" }, + "trusted_device_ttl_days": { + "description": "TrustedDeviceTTLDays is how long a trusted device skips the login MFA challenge.", + "type": "integer" + }, "trusted_devices": { "type": "array", "items": { diff --git a/internal/gateway/proxy/approval_denial.go b/internal/gateway/proxy/approval_denial.go new file mode 100644 index 00000000..b8c4693c --- /dev/null +++ b/internal/gateway/proxy/approval_denial.go @@ -0,0 +1,66 @@ +package proxy + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/DocSpring/rack-gateway/internal/gateway/db" + gtwlog "github.com/DocSpring/rack-gateway/internal/gateway/logging" + "github.com/DocSpring/rack-gateway/internal/gateway/rbac" +) + +// approvalDenial is the 403 for an API token whose deploy_with_approval didn't cover the request. It says why, from +// the token's own newest request for the app, so a CI log explains the failure. The token can already read its own +// requests, so this reveals nothing new. +func (h *Handler) approvalDenial(resource rbac.Resource, action rbac.Action, tokenID int64, app string) error { + return &deployApprovalError{ + status: http.StatusForbidden, + message: strings.TrimSuffix(forbiddenMessage(resource, action), ".") + ": " + + h.approvalDenialReason(tokenID, app, time.Now()), + } +} + +func (h *Handler) approvalDenialReason(tokenID int64, app string, now time.Time) string { + requests, err := h.database.ListDeployApprovalRequests(db.DeployApprovalRequestListOptions{ + TokenID: tokenID, + App: app, + Limit: 1, + }) + if err != nil { + gtwlog.Errorf("proxy: deploy approval lookup for a denial message failed: %v", err) + return fmt.Sprintf("this API token has no usable deploy approval for %s", app) + } + if len(requests) == 0 { + return fmt.Sprintf("this API token has no deploy approval request for %s "+ + "(request one with rack-gateway deploy-approval request)", app) + } + newest := requests[0] + return fmt.Sprintf("this API token has no usable deploy approval for %s: its newest request, %s (commit %s), %s", + app, newest.PublicID, shortCommit(newest.GitCommitHash), describeApprovalState(newest, now)) +} + +// describeApprovalState says why a deploy approval request doesn't allow the request. +func describeApprovalState(req *db.DeployApprovalRequest, now time.Time) string { + switch req.Status { + case db.DeployApprovalRequestStatusPending: + return "is waiting for approval" + case db.DeployApprovalRequestStatusRejected: + return "was rejected" + case db.DeployApprovalRequestStatusDeployed: + return "has already been deployed" + case db.DeployApprovalRequestStatusExpired: + if req.ApprovalExpiresAt != nil { + return "expired at " + req.ApprovalExpiresAt.UTC().Format(time.RFC3339) + } + return "expired before it was approved" + case db.DeployApprovalRequestStatusApproved: + if req.ApprovalExpiresAt != nil && now.After(*req.ApprovalExpiresAt) { + return "was approved, but its approval window ended at " + req.ApprovalExpiresAt.UTC().Format(time.RFC3339) + } + return "is approved, but this request isn't part of its deploy (a different upload, build, release or command)" + default: + return "is " + req.Status + } +} diff --git a/internal/gateway/proxy/approval_denial_test.go b/internal/gateway/proxy/approval_denial_test.go new file mode 100644 index 00000000..0bd32c9a --- /dev/null +++ b/internal/gateway/proxy/approval_denial_test.go @@ -0,0 +1,77 @@ +package proxy + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/DocSpring/rack-gateway/internal/gateway/auth" + "github.com/DocSpring/rack-gateway/internal/gateway/db" + "github.com/DocSpring/rack-gateway/internal/gateway/rbac" +) + +func TestDescribeApprovalState(t *testing.T) { + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + ended := now.Add(-time.Minute) + open := now.Add(time.Hour) + cases := []struct { + status string + expires *time.Time + want string + }{ + {db.DeployApprovalRequestStatusPending, nil, "is waiting for approval"}, + {db.DeployApprovalRequestStatusRejected, nil, "was rejected"}, + {db.DeployApprovalRequestStatusDeployed, &open, "has already been deployed"}, + {db.DeployApprovalRequestStatusExpired, nil, "expired before it was approved"}, + {db.DeployApprovalRequestStatusExpired, &ended, "expired at 2026-10-13T11:59:00Z"}, + {db.DeployApprovalRequestStatusApproved, &ended, "approval window ended at 2026-10-13T11:59:00Z"}, + {db.DeployApprovalRequestStatusApproved, &open, "this request isn't part of its deploy"}, + } + for _, tc := range cases { + req := &db.DeployApprovalRequest{Status: tc.status, ApprovalExpiresAt: tc.expires} + require.Contains(t, describeApprovalState(req, now), tc.want, tc.status) + } +} + +// A token refused by its deploy approval is told why: no request at all, or what its newest request is waiting on. +func TestApprovalDenialSaysWhy(t *testing.T) { + h, database := newProxyForDeployApprovalTest(t) + deployer := createUserWithRole(t, database, "deployer@example.com", "deployer") + perms := []string{rbac.Convox(rbac.ResourceDeploy, rbac.ActionDeployWithApproval)} + token, err := database.CreateAPIToken(strings.Repeat("b", 64), "ci", deployer.ID, perms, nil, nil) + require.NoError(t, err) + + promote := func() error { + t.Helper() + caller := &auth.User{ + Email: deployer.Email, IsAPIToken: true, TokenID: &token.ID, TokenName: "ci", + Permissions: perms, DBUser: deployer, + } + req := httptest.NewRequest(http.MethodPost, "/apps/test-app/releases/RABC/promote", nil) + allowed, _, err := h.evaluateAPITokenPermission(req, caller, rbac.ResourceRelease, rbac.ActionPromote) + require.False(t, allowed) + var denial *deployApprovalError + require.ErrorAs(t, err, &denial) + require.Equal(t, http.StatusForbidden, denial.status) + return err + } + + require.EqualError(t, promote(), "You don't have permission to promote releases: this API token has no deploy "+ + "approval request for test-app (request one with rack-gateway deploy-approval request)") + + request, err := database.CreateDeployApprovalRequest( + "Deploy", "test-app", strings.Repeat("ab12", 10), "main", "", []byte("{}"), deployer.ID, nil, token.ID, + ) + require.NoError(t, err) + require.ErrorContains(t, promote(), + fmt.Sprintf("its newest request, %s (commit ab12ab1), is waiting for approval", request.PublicID)) + + _, err = database.RejectDeployApprovalRequest(request.ID, deployer.ID, "no") + require.NoError(t, err) + require.ErrorContains(t, promote(), "was rejected") +} diff --git a/internal/gateway/proxy/token_permissions.go b/internal/gateway/proxy/token_permissions.go index 4b196bb0..fe2a0c30 100644 --- a/internal/gateway/proxy/token_permissions.go +++ b/internal/gateway/proxy/token_permissions.go @@ -98,22 +98,22 @@ func (h *Handler) evaluateAPITokenPermission( return false, nil, fmt.Errorf("database unavailable for deploy approvals") } - app, ok := h.resolveAppOrDeny(r.URL.Path, deny) - if !ok { - return false, nil, nil + app := extractAppFromPath(r.URL.Path) + if app == "" { + return deny() } req, err := h.findDeployApprovalForResource(r, deny, *authUser.TokenID, app, resource, action) if err != nil { if errors.Is(err, db.ErrDeployApprovalRequestNotFound) { gtwlog.DebugTopicf(gtwlog.TopicDeployApproval, "deploy approval lookup failed: not found") - return deny() + return false, nil, h.approvalDenial(resource, action, *authUser.TokenID, app) } gtwlog.DebugTopicf(gtwlog.TopicDeployApproval, "deploy approval lookup failed: %v", err) return false, nil, err } - if ok := commonApprovalChecks(req, deny); !ok { - return false, nil, nil + if !usableApproval(req, time.Now()) { + return false, nil, h.approvalDenial(resource, action, *authUser.TokenID, app) } tracker := &deployApprovalTracker{ @@ -179,19 +179,10 @@ func approvalsGloballyDisabled(h *Handler) bool { return !enabled } -func (_ *Handler) resolveAppOrDeny(path string, deny denyFunc) (string, bool) { - app := extractAppFromPath(path) - if app == "" { - _, _, _ = deny() - return "", false - } - return app, true -} - -func commonApprovalChecks(req *db.DeployApprovalRequest, deny denyFunc) bool { +// usableApproval reports whether the approval the request resolved to is approved and inside its window. +func usableApproval(req *db.DeployApprovalRequest, now time.Time) bool { if req == nil { gtwlog.DebugTopicf(gtwlog.TopicDeployApproval, "deploy approval lookup returned nil request") - _, _, _ = deny() return false } gtwlog.DebugTopicf( @@ -199,9 +190,8 @@ func commonApprovalChecks(req *db.DeployApprovalRequest, deny denyFunc) bool { "deploy approval found: id=%s status=%s process_ids=%v", req.PublicID, req.Status, req.ProcessIDs, ) - if req.ApprovalExpiresAt != nil && time.Now().After(*req.ApprovalExpiresAt) { + if req.ApprovalExpiresAt != nil && now.After(*req.ApprovalExpiresAt) { gtwlog.DebugTopicf(gtwlog.TopicDeployApproval, "deploy approval expired") - _, _, _ = deny() return false } if req.Status != db.DeployApprovalRequestStatusApproved { @@ -210,7 +200,6 @@ func commonApprovalChecks(req *db.DeployApprovalRequest, deny denyFunc) bool { "deploy approval status check failed: expected=approved actual=%s", req.Status, ) - _, _, _ = deny() return false } return true diff --git a/internal/gateway/security/notifier.go b/internal/gateway/security/notifier.go index 73993a71..af5fed9a 100644 --- a/internal/gateway/security/notifier.go +++ b/internal/gateway/security/notifier.go @@ -24,7 +24,6 @@ type Notifier struct { emailSender email.Sender auditLogger *audit.Logger database *db.Database - adminEmails []string emailsEnabled bool jobsClient *jobs.Client @@ -41,7 +40,6 @@ func NewNotifier( emailSender email.Sender, auditLogger *audit.Logger, database *db.Database, - adminEmails []string, jobsClient *jobs.Client, ) *Notifier { // Check if email sender is actually configured (not noop) @@ -57,7 +55,6 @@ func NewNotifier( emailSender: emailSender, auditLogger: auditLogger, database: database, - adminEmails: adminEmails, emailsEnabled: emailsEnabled, jobsClient: jobsClient, recentEmails: make(map[string]time.Time), @@ -101,6 +98,19 @@ func (n *Notifier) cleanupRecentEmails() { // shouldSendEmail checks if we should send this email based on deduplication window // enqueueSecurityNotification enqueues a security notification if deduplication allows +// admins returns the admins to alert, looked up now so admins added or removed since startup are respected. +func (n *Notifier) admins() []string { + if n.database == nil { + return nil + } + emails, err := n.database.UnlockedAdminEmails() + if err != nil { + log.Printf(`{"level":"error","event":"admin_lookup_failed","error":%q}`, err) + return nil + } + return emails +} + func (n *Notifier) enqueueSecurityNotification(recipient, subject, eventType string, args river.JobArgs) { if recipient == "" && !strings.HasPrefix(eventType, "admin") { return @@ -283,9 +293,13 @@ func (n *Notifier) RateLimitExceeded(userEmail, userName, path, ipAddress, userA // Also notify admins about rate limit violations (potential attack) - with deduplication // Use IP address in the hash so we deduplicate per-IP, not globally + admins := n.admins() + if len(admins) == 0 { + return + } adminKey := fmt.Sprintf("admins-%s", ipAddress) n.enqueueSecurityNotification(adminKey, "Rate Limit Exceeded", "rate_limit_admin", jobemail.RateLimitAdminArgs{ - AdminEmails: n.adminEmails, + AdminEmails: admins, UserEmail: userEmail, UserName: userName, Path: path, @@ -336,13 +350,17 @@ func (n *Notifier) SuspiciousActivity( ) // Notify admins about suspicious activity (with deduplication per user+reason) + admins := n.admins() + if len(admins) == 0 { + return + } adminKey := fmt.Sprintf("admins-%s-%s", userEmail, reason) n.enqueueSecurityNotification( adminKey, "Suspicious Activity", "suspicious_activity_admin", jobemail.SuspiciousActivityAdminArgs{ - AdminEmails: n.adminEmails, + AdminEmails: admins, UserEmail: userEmail, UserName: userName, Reason: reason, diff --git a/internal/gateway/security/notifier_test.go b/internal/gateway/security/notifier_test.go new file mode 100644 index 00000000..78b0a3f7 --- /dev/null +++ b/internal/gateway/security/notifier_test.go @@ -0,0 +1,28 @@ +package security + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/DocSpring/rack-gateway/internal/gateway/email" + "github.com/DocSpring/rack-gateway/internal/gateway/testutil/dbtest" +) + +// Alerts go to the admins at the time of the alert: one added after startup gets them, and a locked one doesn't. +func TestNotifierLooksUpAdminsWhenAlerting(t *testing.T) { + database := dbtest.NewDatabase(t) + n := NewNotifier(email.NoopSender{}, nil, database, nil) + t.Cleanup(n.Stop) + require.Empty(t, n.admins()) + + _, err := database.CreateUser("admin@example.com", "Admin", []string{"admin"}) + require.NoError(t, err) + locked, err := database.CreateUser("locked@example.com", "Locked", []string{"admin"}) + require.NoError(t, err) + require.NoError(t, database.LockUser(locked.ID, "away", nil)) + _, err = database.CreateUser("viewer@example.com", "Viewer", []string{"viewer"}) + require.NoError(t, err) + + require.Equal(t, []string{"admin@example.com"}, n.admins()) +} diff --git a/web/src/api/openapi.json b/web/src/api/openapi.json index a96a0c44..dfaf8c94 100644 --- a/web/src/api/openapi.json +++ b/web/src/api/openapi.json @@ -6447,6 +6447,7 @@ "enrolled", "methods", "required", + "trusted_device_ttl_days", "trusted_devices", "webauthn_available" ], @@ -6472,6 +6473,10 @@ "required": { "type": "boolean" }, + "trusted_device_ttl_days": { + "description": "TrustedDeviceTTLDays is how long a trusted device skips the login MFA challenge.", + "type": "integer" + }, "trusted_devices": { "type": "array", "items": { diff --git a/web/src/api/schemas/handlersMFAStatusResponse.ts b/web/src/api/schemas/handlersMFAStatusResponse.ts index 526b0b38..f51b18e0 100644 --- a/web/src/api/schemas/handlersMFAStatusResponse.ts +++ b/web/src/api/schemas/handlersMFAStatusResponse.ts @@ -16,6 +16,8 @@ export interface HandlersMFAStatusResponse { preferred_method?: string; recent_step_up_expires_at?: string; required: boolean; + /** TrustedDeviceTTLDays is how long a trusted device skips the login MFA challenge. */ + trusted_device_ttl_days: number; trusted_devices: HandlersTrustedDeviceResponse[]; webauthn_available: boolean; } diff --git a/web/src/api/types.generated.ts b/web/src/api/types.generated.ts index f10c14a1..34fa9717 100644 --- a/web/src/api/types.generated.ts +++ b/web/src/api/types.generated.ts @@ -5425,6 +5425,8 @@ export interface components { preferred_method?: string; recent_step_up_expires_at?: string; required: boolean; + /** @description TrustedDeviceTTLDays is how long a trusted device skips the login MFA challenge. */ + trusted_device_ttl_days: number; trusted_devices: components["schemas"]["handlers.TrustedDeviceResponse"][]; webauthn_available: boolean; }; diff --git a/web/src/components/account-security/enrollment-dialog.tsx b/web/src/components/account-security/enrollment-dialog.tsx index 04582585..222d62dd 100644 --- a/web/src/components/account-security/enrollment-dialog.tsx +++ b/web/src/components/account-security/enrollment-dialog.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import type { MFAStatusResponse } from '@/lib/api' +import { trustLabel } from '@/lib/time' import type { EnrollmentState, MFAMethodType } from './types' type EnrollmentDialogProps = { @@ -197,7 +198,7 @@ export function EnrollmentDialog({ onChange={(event) => onTrustEnrollmentDeviceChange(event.target.checked)} type="checkbox" /> - Trust this browser for 30 days + {trustLabel('browser', status?.trusted_device_ttl_days)} diff --git a/web/src/components/mfa-verification-form.test.tsx b/web/src/components/mfa-verification-form.test.tsx index 1a2e2a51..5f565a54 100644 --- a/web/src/components/mfa-verification-form.test.tsx +++ b/web/src/components/mfa-verification-form.test.tsx @@ -20,9 +20,13 @@ function mfaStatus(unusedBackupCodes: number) { trusted_devices: [], backup_codes: { total: 10, unused: unusedBackupCodes }, webauthn_available: false, + trusted_device_ttl_days: 14, } } +// The label shows the gateway's trusted device TTL. +const TRUST_LABEL = 'Trust this device for 14 days' + function renderForm() { const onVerify = vi.fn(() => Promise.resolve()) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -50,7 +54,7 @@ describe('MFAVerificationForm backup codes', () => { await waitForMethodsToLoad() await user.click(screen.getByRole('button', { name: 'Use a backup code' })) - expect(screen.queryByText('Trust this device for 30 days')).not.toBeInTheDocument() + expect(screen.queryByText(TRUST_LABEL)).not.toBeInTheDocument() const verifyButton = screen.getByRole('button', { name: 'Verify backup code' }) await user.type(screen.getByLabelText('Backup code'), 'abcdef-01234') @@ -78,7 +82,7 @@ describe('MFAVerificationForm backup codes', () => { await user.click(screen.getByRole('button', { name: 'Back to authenticator app' })) expect(screen.queryByLabelText('Backup code')).not.toBeInTheDocument() - expect(screen.getByText('Trust this device for 30 days')).toBeInTheDocument() + expect(screen.getByText(TRUST_LABEL)).toBeInTheDocument() }) it('hides the option when no backup codes are left', async () => { diff --git a/web/src/components/mfa-verification-form.tsx b/web/src/components/mfa-verification-form.tsx index e61dd61b..d942cc83 100644 --- a/web/src/components/mfa-verification-form.tsx +++ b/web/src/components/mfa-verification-form.tsx @@ -248,6 +248,7 @@ export function MFAVerificationForm({ const renderProps: RenderProps = { showTrustDevice, + trustDeviceDays: mfaStatus?.trusted_device_ttl_days, trustDevice, setTrustDevice, isVerifying, diff --git a/web/src/components/mfa-verification-form/form-sections.tsx b/web/src/components/mfa-verification-form/form-sections.tsx index 40d98f39..75abbd8f 100644 --- a/web/src/components/mfa-verification-form/form-sections.tsx +++ b/web/src/components/mfa-verification-form/form-sections.tsx @@ -2,6 +2,7 @@ import type { MutableRefObject, ReactNode } from 'react' import { LoadingSpinner } from '@/components/loading-spinner' import { MFAInput } from '@/components/mfa-input' import { Button } from '@/components/ui/button' +import { trustLabel } from '@/lib/time' import { BackupCodeForm } from './backup-code-form' const DIGITS_ONLY_REGEX = /^\d+$/ @@ -11,6 +12,8 @@ export type FormView = 'loading' | 'totp' | 'webauthn' | 'backup' export type RenderProps = { showTrustDevice: boolean + // How long a trusted device skips the login MFA challenge, once the MFA status has loaded. + trustDeviceDays?: number trustDevice: boolean setTrustDevice: (value: boolean) => void isVerifying: boolean @@ -34,7 +37,7 @@ export type RenderProps = { } function TrustDeviceOption( - props: Pick + props: Pick ) { if (!props.showTrustDevice) { return null @@ -47,7 +50,7 @@ function TrustDeviceOption( onChange={(event) => props.setTrustDevice(event.target.checked)} type="checkbox" /> - Trust this device for 30 days + {trustLabel('device', props.trustDeviceDays)} ) @@ -91,7 +94,7 @@ function renderLoadingState(props: Pick) {
)} diff --git a/web/src/lib/time.test.ts b/web/src/lib/time.test.ts new file mode 100644 index 00000000..bdceb2df --- /dev/null +++ b/web/src/lib/time.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { trustLabel } from './time' + +describe('trustLabel', () => { + it('shows the trusted device TTL', () => { + expect(trustLabel('device', 14)).toBe('Trust this device for 14 days') + expect(trustLabel('browser', 1)).toBe('Trust this browser for 1 day') + }) + + it('leaves the duration out until it is known', () => { + expect(trustLabel('device')).toBe('Trust this device') + expect(trustLabel('device', 0)).toBe('Trust this device') + }) +}) diff --git a/web/src/lib/time.ts b/web/src/lib/time.ts index 46736605..b419eea1 100644 --- a/web/src/lib/time.ts +++ b/web/src/lib/time.ts @@ -21,3 +21,12 @@ export function formatElapsed(start?: string | null, end?: string | null): strin } return `${seconds}s` } + +// trustLabel is the "trust this device" checkbox's label: how long a trusted device skips the login MFA challenge +// (the gateway's trusted device TTL), once it's known. +export function trustLabel(subject: 'browser' | 'device', days?: number): string { + if (!days || days <= 0) { + return `Trust this ${subject}` + } + return `Trust this ${subject} for ${days} ${days === 1 ? 'day' : 'days'}` +}