From 97cd2f8aecc4e2c5fff5cb85af180117d1ef1503 Mon Sep 17 00:00:00 2001
From: Nathan Broadbent
Date: Tue, 13 Oct 2026 15:22:57 +1300
Subject: [PATCH] feat: optional different deploy approver, and admin Reset MFA
- A global setting, deploy_approval_require_different_approver (off by default), refuses
approving or extending a deploy approval by the user who created the request or owns
its API token, with a 403 that says which. It fails closed when the setting can't be
read, and the approval's audit entry records that the rule applied. It's on the
Settings page and settable with RGW_SETTING_*. Deploy approval requests now load
their token's owner (TargetUserID was never set, so the owner of the target token
couldn't view a request someone else created for it) (#131)
- Admins can reset another user's MFA (POST /api/v1/users/:email/mfa/reset, Reset MFA on
the user's page): it removes their MFA methods, backup codes and trusted devices and
revokes their sessions in one transaction, keeps their API tokens, needs MFA on the
request, is audited as user.mfa_reset, and emails the user (#135)
Fixes #131, fixes #135
Co-Authored-By: Claude Opus 5.5
---
.../configuration/environment-variables.mdx | 1 +
.../docs/development/api-reference.mdx | 1 +
.../integrations/deploy-approvals/index.mdx | 10 ++
.../deploy-approvals/workflow.mdx | 9 +-
.../docs/operations/troubleshooting.mdx | 5 +-
.../content/docs/security/compliance/soc2.mdx | 8 +-
.../docs/user-guide/mfa/backup-codes.mdx | 5 +-
.../user-guide/web-ui/user-management.mdx | 13 ++
internal/gateway/audit/constants.go | 1 +
internal/gateway/db/deploy_approval_scan.go | 7 +-
internal/gateway/db/mfa.go | 16 ++-
internal/gateway/handlers/admin_users_lock.go | 6 +-
.../gateway/handlers/admin_users_mfa_reset.go | 81 +++++++++++
.../handlers/admin_users_mfa_reset_test.go | 118 +++++++++++++++
.../gateway/handlers/deploy_approval_admin.go | 28 ++--
.../handlers/deploy_approval_approver.go | 82 +++++++++++
.../handlers/deploy_approval_approver_test.go | 136 ++++++++++++++++++
.../gateway/openapi/generated/swagger.json | 67 +++++++++
internal/gateway/rbac/audit_names.go | 1 +
internal/gateway/rbac/gateway_routes.go | 1 +
internal/gateway/routes/route_registration.go | 1 +
internal/gateway/settings/defaults.go | 20 +--
internal/gateway/settings/groups.go | 1 +
internal/gateway/settings/service_deploy.go | 31 ++--
web/src/api/generated.ts | 23 +++
web/src/api/openapi.json | 90 ++++++++++++
web/src/api/types.generated.ts | 88 ++++++++++++
.../components/user-mfa-reset-dialog.test.tsx | 57 ++++++++
web/src/components/user-mfa-reset-dialog.tsx | 71 +++++++++
web/src/lib/api.ts | 4 +
web/src/lib/generated/mfa-requirements.ts | 6 +
web/src/pages/settings-page.test.tsx | 25 ++++
.../pages/settings/deploy-approvals-card.tsx | 135 ++++++++---------
web/src/pages/user-page.tsx | 9 ++
web/src/pages/user/header-section.tsx | 21 ++-
web/src/pages/user/use-user-danger-zone.ts | 5 +
36 files changed, 1053 insertions(+), 130 deletions(-)
create mode 100644 internal/gateway/handlers/admin_users_mfa_reset.go
create mode 100644 internal/gateway/handlers/admin_users_mfa_reset_test.go
create mode 100644 internal/gateway/handlers/deploy_approval_approver.go
create mode 100644 internal/gateway/handlers/deploy_approval_approver_test.go
create mode 100644 web/src/components/user-mfa-reset-dialog.test.tsx
create mode 100644 web/src/components/user-mfa-reset-dialog.tsx
diff --git a/docs/src/content/docs/configuration/environment-variables.mdx b/docs/src/content/docs/configuration/environment-variables.mdx
index af896a3f..257bd585 100644
--- a/docs/src/content/docs/configuration/environment-variables.mdx
+++ b/docs/src/content/docs/configuration/environment-variables.mdx
@@ -194,6 +194,7 @@ startup log lists them, and the settings page marks them and lets an admin clear
| `RGW_SETTING_MFA_TRUSTED_DEVICE_TTL_DAYS` | Trusted device TTL |
| `RGW_SETTING_DEPLOY_APPROVALS_ENABLED` | Enable deploy approvals |
| `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_DEFAULT_CI_PROVIDER` | Default CI provider |
| `RGW_SETTING_DEFAULT_VCS_PROVIDER` | Default VCS provider |
diff --git a/docs/src/content/docs/development/api-reference.mdx b/docs/src/content/docs/development/api-reference.mdx
index 3f7d24c8..931ef751 100644
--- a/docs/src/content/docs/development/api-reference.mdx
+++ b/docs/src/content/docs/development/api-reference.mdx
@@ -100,6 +100,7 @@ All under `/auth/mfa`:
- `POST /users/:email/sessions/:sessionID/revoke`
- `POST /users/:email/sessions/revoke_all`
- `POST /users/:email/lock`, `POST /users/:email/unlock`
+- `POST /users/:email/mfa/reset`: removes another user's MFA methods, backup codes and trusted devices and signs them out
- `GET /api-tokens`, `GET /api-tokens/:tokenID`, `POST /api-tokens`
- `PUT /api-tokens/:tokenID`, `DELETE /api-tokens/:tokenID`
- `GET /api-tokens/permissions`
diff --git a/docs/src/content/docs/integrations/deploy-approvals/index.mdx b/docs/src/content/docs/integrations/deploy-approvals/index.mdx
index 91d6bd43..ea97f530 100644
--- a/docs/src/content/docs/integrations/deploy-approvals/index.mdx
+++ b/docs/src/content/docs/integrations/deploy-approvals/index.mdx
@@ -138,6 +138,15 @@ Approving, rejecting and extending a request ask for MFA every time, even within
- Auditable proof of human authorization
- API tokens can't approve
+### Different Approver (Optional)
+
+For segregation of duties, turn on **Require a different approver** (Settings > Deploy Approvals, or
+`RGW_SETTING_DEPLOY_APPROVAL_REQUIRE_DIFFERENT_APPROVER=true`). Nobody can then approve or extend a deploy they
+asked for: the user who created the request, or the owner of the API token it's for. They get a 403 that says
+which. The approval's audit entry records `different_approver_required: true`.
+
+It's off by default, so a team with one admin can still deploy.
+
### Time-Limited Approvals
Approvals expire after a configurable window (default 15 minutes):
@@ -203,6 +212,7 @@ The `deploy_approval_requests` table tracks the complete lifecycle:
|---------|---------|-------------|
| `RGW_SETTING_DEPLOY_APPROVALS_ENABLED` | `true` | Enable/disable approval checks |
| `RGW_SETTING_DEPLOY_APPROVAL_WINDOW_MINUTES` | `15` | How long approvals remain valid |
+| `RGW_SETTING_DEPLOY_APPROVAL_REQUIRE_DIFFERENT_APPROVER` | `false` | Refuse approvals and extensions by the request's creator or its token's owner |
### Per-App Settings
diff --git a/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx b/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx
index 9b44f8a0..01e14ddf 100644
--- a/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx
+++ b/docs/src/content/docs/integrations/deploy-approvals/workflow.mdx
@@ -142,11 +142,16 @@ The review UI shows:
Server validates the MFA response
-4. **Request marked approved**
+4. **Approver checked**
+
+ With [Require a different approver](/integrations/deploy-approvals/#different-approver-optional) on, the
+ request's creator and its API token's owner are refused with 403
+
+5. **Request marked approved**
Status changes, expiration timer starts
-5. **CI auto-approval triggered**
+6. **CI auto-approval triggered**
If configured, gateway calls CI API
diff --git a/docs/src/content/docs/operations/troubleshooting.mdx b/docs/src/content/docs/operations/troubleshooting.mdx
index fd5f2795..fa58f011 100644
--- a/docs/src/content/docs/operations/troubleshooting.mdx
+++ b/docs/src/content/docs/operations/troubleshooting.mdx
@@ -148,8 +148,9 @@ user's.
### Lost MFA Device
1. Sign in with a backup code, then remove the lost method and enroll a new one under **Account Security**
-2. Without any method or backup code, an admin must delete the user and add them again. That deletes their API
- tokens too, so CI using them needs new tokens
+2. Without any method or backup code, an admin chooses **Reset MFA** on the user's page (after confirming who's
+ asking). It removes their methods, backup codes and trusted devices and signs them out; their API tokens are kept.
+ They set up a new method when they next sign in. If the account was locked by failed attempts, unlock it too
## Database Issues
diff --git a/docs/src/content/docs/security/compliance/soc2.mdx b/docs/src/content/docs/security/compliance/soc2.mdx
index 2971091d..83c07ca4 100644
--- a/docs/src/content/docs/security/compliance/soc2.mdx
+++ b/docs/src/content/docs/security/compliance/soc2.mdx
@@ -141,9 +141,11 @@ graph LR
- CI/CD deployments require human approval
- Only users with the approve permission (admins) can approve, with MFA on the request
- Full audit trail of approvals
+- Optionally, a different approver: with `deploy_approval_require_different_approver` on, nobody can approve or
+ extend a deploy they requested (they created the request, or own its API token), and each approval's audit
+ entry records that the rule applied
-The gateway doesn't stop a token's owner approving their own pipeline's request. If your controls require a
-different approver, give CI tokens to a user who isn't an approver, or check it in your review process.
+The setting is off by default. Turn it on if your controls require segregation of duties.
### CC9: Risk Mitigation
@@ -240,7 +242,7 @@ curl -b "session_token=TOKEN" \
### Change Management (CC8)
- [ ] Deploy approvals enabled
-- [ ] Approvers are not the owners of the CI tokens they approve
+- [ ] Different approver required (`RGW_SETTING_DEPLOY_APPROVAL_REQUIRE_DIFFERENT_APPROVER=true`)
- [ ] CI/CD uses dedicated tokens
- [ ] Change log maintained
diff --git a/docs/src/content/docs/user-guide/mfa/backup-codes.mdx b/docs/src/content/docs/user-guide/mfa/backup-codes.mdx
index 7c5e7972..49b92b98 100644
--- a/docs/src/content/docs/user-guide/mfa/backup-codes.mdx
+++ b/docs/src/content/docs/user-guide/mfa/backup-codes.mdx
@@ -15,8 +15,9 @@ Backup codes provide emergency access if you lose your primary MFA device.
- **Backup Codes** in Account Security shows how many unused codes you have and when they were generated
## Getting Backup Codes
diff --git a/docs/src/content/docs/user-guide/web-ui/user-management.mdx b/docs/src/content/docs/user-guide/web-ui/user-management.mdx
index 54bd769b..a4769eac 100644
--- a/docs/src/content/docs/user-guide/web-ui/user-management.mdx
+++ b/docs/src/content/docs/user-guide/web-ui/user-management.mdx
@@ -67,6 +67,19 @@ unlocked. A lock needs a reason: the user's email includes it, and in the web UI
**Unlock Account** reverses it. The user is emailed when locked and when unlocked. You can't lock or delete your own account.
+## Resetting MFA (Admin)
+
+For a user who has lost every MFA method and their backup codes, choose **Reset MFA** on their detail page (shown for
+users who have MFA set up) and confirm. Confirm who's asking before you do: the reset lets the user sign in with only
+their Google account until they set up a new method. It needs MFA from you on the request.
+
+The reset removes their MFA methods, backup codes and trusted devices and signs them out everywhere. Their API tokens
+are kept, so CI keeps working. They're emailed, and they set up a new method when they next sign in. The audit log
+records it as `user.mfa_reset`.
+
+You can't reset your own MFA: manage your methods under **Account Security**. A reset doesn't unlock an account; if
+failed MFA attempts locked it, unlock it as well.
+
## Deleting Users (Admin)
diff --git a/internal/gateway/audit/constants.go b/internal/gateway/audit/constants.go
index b0598173..2fd56d12 100644
--- a/internal/gateway/audit/constants.go
+++ b/internal/gateway/audit/constants.go
@@ -54,6 +54,7 @@ const (
ActionVerbExport = "export"
ActionVerbFailed = "failed"
ActionVerbLock = "lock"
+ ActionVerbMFAReset = "mfa_reset"
ActionVerbOAuthFailed = "oauth_failed"
ActionVerbParamsSet = "params.set"
ActionVerbReject = "reject"
diff --git a/internal/gateway/db/deploy_approval_scan.go b/internal/gateway/db/deploy_approval_scan.go
index accd67ab..1d959e2e 100644
--- a/internal/gateway/db/deploy_approval_scan.go
+++ b/internal/gateway/db/deploy_approval_scan.go
@@ -24,6 +24,7 @@ SELECT
dr.target_api_token_id,
target_token.public_id,
target_token.name,
+ target_token.user_id,
dr.approved_by_user_id,
approved_user.email,
approved_user.name,
@@ -61,6 +62,7 @@ type deployApprovalNullables struct {
createdTokenName sql.NullString
targetTokenPublic sql.NullString
targetTokenName sql.NullString
+ targetUserID sql.NullInt64
approvedByUserID sql.NullInt64
approvedByEmail sql.NullString
approvedByName sql.NullString
@@ -90,7 +92,7 @@ func scanDeployApprovalRequest(scanner rowScanner) (*DeployApprovalRequest, erro
&dr.ID, &dr.PublicID, &dr.Message, &dr.Status, &dr.CreatedAt, &dr.UpdatedAt,
&n.createdByUserID, &n.createdByEmail, &n.createdByName,
&n.createdByTokenID, &n.createdTokenPublic, &n.createdTokenName,
- &dr.TargetAPITokenID, &n.targetTokenPublic, &n.targetTokenName,
+ &dr.TargetAPITokenID, &n.targetTokenPublic, &n.targetTokenName, &n.targetUserID,
&n.approvedByUserID, &n.approvedByEmail, &n.approvedByName,
&n.approvedAt, &n.approvalExpiresAt,
&n.rejectedByUserID, &n.rejectedByEmail, &n.rejectedByName, &n.rejectedAt,
@@ -140,6 +142,9 @@ func applyCreatorFields(dr *DeployApprovalRequest, n *deployApprovalNullables) {
if n.targetTokenName.Valid {
dr.TargetAPITokenName = n.targetTokenName.String
}
+ if n.targetUserID.Valid {
+ dr.TargetUserID = &n.targetUserID.Int64
+ }
}
func applyApprovalFields(dr *DeployApprovalRequest, n *deployApprovalNullables) {
diff --git a/internal/gateway/db/mfa.go b/internal/gateway/db/mfa.go
index 575c8f9d..1628dc5c 100644
--- a/internal/gateway/db/mfa.go
+++ b/internal/gateway/db/mfa.go
@@ -66,9 +66,10 @@ func (d *Database) SetUserMFAEnrolled(userID int64, enrolled bool) error {
return nil
}
-// ResetUserMFA completely removes all MFA configuration for a user.
-// This includes methods, backup codes, trusted devices, and active sessions.
-func (d *Database) ResetUserMFA(userID int64) error {
+// ResetUserMFA removes a user's MFA for an admin (resetBy): their methods, backup codes and trusted devices, and
+// it revokes their sessions, all in one transaction. Their API tokens are kept. They enroll a new method when they
+// next sign in.
+func (d *Database) ResetUserMFA(userID, resetBy int64) error {
tx, err := d.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin MFA reset: %w", err)
@@ -84,7 +85,7 @@ func (d *Database) ResetUserMFA(userID int64) error {
revokeDevicesQuery := `
UPDATE trusted_devices
- SET revoked_at = NOW(), revoked_reason = 'reset', updated_at = NOW()
+ SET revoked_at = NOW(), revoked_reason = 'admin_mfa_reset', updated_at = NOW()
WHERE user_id = ? AND revoked_at IS NULL
`
if _, err := d.execTx(tx, revokeDevicesQuery, userID); err != nil {
@@ -93,14 +94,15 @@ func (d *Database) ResetUserMFA(userID int64) error {
revokeSessionsQuery := `
UPDATE user_sessions
- SET revoked_at = NOW(), updated_at = NOW()
+ SET revoked_at = NOW(), revoked_by_user_id = ?, updated_at = NOW()
WHERE user_id = ? AND revoked_at IS NULL
`
- if _, err := d.execTx(tx, revokeSessionsQuery, userID); err != nil {
+ if _, err := d.execTx(tx, revokeSessionsQuery, resetBy, userID); err != nil {
return fmt.Errorf("failed to revoke active sessions: %w", err)
}
- if _, err := d.execTx(tx, "UPDATE users SET mfa_enrolled = FALSE WHERE id = ?", userID); err != nil {
+ clearEnrolled := "UPDATE users SET mfa_enrolled = FALSE, updated_at = NOW() WHERE id = ?"
+ if _, err := d.execTx(tx, clearEnrolled, userID); err != nil {
return fmt.Errorf("failed to update user MFA flag: %w", err)
}
diff --git a/internal/gateway/handlers/admin_users_lock.go b/internal/gateway/handlers/admin_users_lock.go
index 01b6ac3b..f6c41bc3 100644
--- a/internal/gateway/handlers/admin_users_lock.go
+++ b/internal/gateway/handlers/admin_users_lock.go
@@ -64,7 +64,7 @@ func (h *AdminHandler) LockUser(c *gin.Context) {
return
}
- user, adminUser, ok := h.validateLockUnlockUsers(c, userEmail, start, audit.ActionVerbLock)
+ user, adminUser, ok := h.loadUserAndAdmin(c, userEmail, start, audit.ActionVerbLock)
if !ok {
return
}
@@ -134,7 +134,7 @@ func (h *AdminHandler) UnlockUser(c *gin.Context) {
return
}
- user, adminUser, ok := h.validateLockUnlockUsers(c, userEmail, start, audit.ActionVerbUnlock)
+ user, adminUser, ok := h.loadUserAndAdmin(c, userEmail, start, audit.ActionVerbUnlock)
if !ok {
return
}
@@ -169,7 +169,7 @@ func (h *AdminHandler) UnlockUser(c *gin.Context) {
)
}
-func (h *AdminHandler) validateLockUnlockUsers(
+func (h *AdminHandler) loadUserAndAdmin(
c *gin.Context,
userEmail string,
start time.Time,
diff --git a/internal/gateway/handlers/admin_users_mfa_reset.go b/internal/gateway/handlers/admin_users_mfa_reset.go
new file mode 100644
index 00000000..7a776422
--- /dev/null
+++ b/internal/gateway/handlers/admin_users_mfa_reset.go
@@ -0,0 +1,81 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/DocSpring/rack-gateway/internal/gateway/audit"
+ gtwlog "github.com/DocSpring/rack-gateway/internal/gateway/logging"
+ "github.com/DocSpring/rack-gateway/internal/gateway/rbac"
+)
+
+// ResetUserMFA godoc
+// @Summary Reset a user's MFA
+// @Description Removes a user's MFA methods, backup codes and trusted devices and signs them out, so they can enroll
+// @Description a new method at their next sign-in. Their API tokens are kept. An admin can't reset their own MFA.
+// @Tags admin
+// @Produce json
+// @Param email path string true "User email"
+// @Success 200 {object} StatusResponse
+// @Failure 400 {object} ErrorResponse
+// @Failure 404 {object} ErrorResponse
+// @Failure 500 {object} ErrorResponse
+// @Security SessionCookie
+// @Security CSRFToken
+// @Router /users/{email}/mfa/reset [post]
+func (h *AdminHandler) ResetUserMFA(c *gin.Context) {
+ start := time.Now()
+ action := audit.BuildAction(rbac.ResourceUser.String(), audit.ActionVerbMFAReset)
+ userEmail := strings.TrimSpace(c.Param("email"))
+ if userEmail == "" {
+ h.respondAuditError(c, http.StatusBadRequest, action, userEmail, "email is required", start, nil)
+ return
+ }
+
+ user, adminUser, ok := h.loadUserAndAdmin(c, userEmail, start, audit.ActionVerbMFAReset)
+ if !ok {
+ return
+ }
+ if user.ID == adminUser.ID {
+ h.respondAuditError(c, http.StatusBadRequest, action, userEmail,
+ "you can't reset your own MFA; manage your methods on the Account Security page", start, nil)
+ return
+ }
+
+ if err := h.database.ResetUserMFA(user.ID, adminUser.ID); err != nil {
+ gtwlog.Errorf("users: MFA reset for %s failed: %v", user.Email, err)
+ h.respondAuditError(c, http.StatusInternalServerError, action, userEmail, "failed to reset MFA", start, nil)
+ return
+ }
+
+ h.sendMFAResetEmail(user.Email)
+
+ h.respondAuditSuccess(
+ c,
+ http.StatusOK,
+ StatusResponse{Status: "reset"},
+ action,
+ userEmail,
+ start,
+ map[string]interface{}{"reset_by": adminUser.Email, "target_user": user.Email},
+ )
+}
+
+func (h *AdminHandler) sendMFAResetEmail(userEmail string) {
+ if h.emailSender == nil {
+ return
+ }
+ subject := "Multi-factor authentication reset"
+ textBody := "An administrator reset your multi-factor authentication. Your MFA methods, backup codes and " +
+ "trusted devices were removed, and you were signed out.\n\n" +
+ "Sign in again to set up a new method. If you didn't ask for this, contact your administrator."
+ htmlBody := "
An administrator reset your multi-factor authentication. Your MFA methods, backup codes and " +
+ "trusted devices were removed, and you were signed out.
" +
+ "
Sign in again to set up a new method. If you didn't ask for this, contact your administrator.