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
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
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 @@ -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`
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/integrations/deploy-approvals/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/operations/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/security/compliance/soc2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/user-guide/mfa/backup-codes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Aside type="danger">
Store backup codes securely. If you lose every MFA method and your backup codes, the only recovery is for an admin
to delete your user and add you again. That also deletes your API tokens.
Store backup codes securely. If you lose every MFA method and your backup codes, ask an admin to
[reset your MFA](/user-guide/web-ui/user-management/#resetting-mfa-admin). You then set up a new method when you next
sign in. Your API tokens are kept.
</Aside>

## Getting Backup Codes
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/user-guide/web-ui/user-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<Steps>
Expand Down
1 change: 1 addition & 0 deletions internal/gateway/audit/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const (
ActionVerbExport = "export"
ActionVerbFailed = "failed"
ActionVerbLock = "lock"
ActionVerbMFAReset = "mfa_reset"
ActionVerbOAuthFailed = "oauth_failed"
ActionVerbParamsSet = "params.set"
ActionVerbReject = "reject"
Expand Down
7 changes: 6 additions & 1 deletion internal/gateway/db/deploy_approval_scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 9 additions & 7 deletions internal/gateway/db/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}

Expand Down
6 changes: 3 additions & 3 deletions internal/gateway/handlers/admin_users_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions internal/gateway/handlers/admin_users_mfa_reset.go
Original file line number Diff line number Diff line change
@@ -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 := "<p>An administrator reset your multi-factor authentication. Your MFA methods, backup codes and " +
"trusted devices were removed, and you were signed out.</p>" +
"<p>Sign in again to set up a new method. If you didn't ask for this, contact your administrator.</p>"
if err := h.emailSender.Send(userEmail, subject, textBody, htmlBody); err != nil {
gtwlog.Errorf("users: MFA reset email to %s failed: %v", userEmail, err)
}
}
Loading