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.