Skip to content

hub: re-evaluate admin role on every login and token refresh#363

Open
ptone wants to merge 1 commit into
mainfrom
scion/admin-promo-on-login
Open

hub: re-evaluate admin role on every login and token refresh#363
ptone wants to merge 1 commit into
mainfrom
scion/admin-promo-on-login

Conversation

@ptone

@ptone ptone commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix admin role to be re-evaluated on every login and token refresh, not just at user creation
  • API login path (provisionUser): changed promotion-only logic to full re-evaluation (supports both promotion and demotion)
  • Web OAuth callback (handleOAuthCallback): added role re-evaluation for existing users (was completely missing)
  • Token refresh (handleAuthRefresh): now re-evaluates role from current admin emails config instead of preserving stale JWT claims

Test plan

  • Existing TestProvisionUser/promotes_member_to_admin_when_config_changes still passes
  • New TestProvisionUser/demotes_admin_to_member_when_removed_from_admin_emails test added and passes
  • All TestProvisionUser subtests pass (9/9)
  • go build ./pkg/hub/ compiles cleanly

Previously admin status was only fully evaluated when a user record was
first created. The API login path (provisionUser) had promotion-only
logic, and the web OAuth callback had no re-evaluation at all. Token
refresh also preserved the original role from the JWT claims.

Now all three paths call determineUserRole / getUserRole on every
invocation, supporting both promotion and demotion when the admin
emails configuration changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces dynamic re-evaluation of user roles (both promotion and demotion) during login and token refresh, along with a test verifying admin demotion. Feedback on these changes highlights two critical security gaps: the OAuth callback handler does not reject suspended users during login, and the token refresh handler fails to verify if a user has been suspended or deleted. Additionally, a background token refresh middleware bypasses these new role and status checks entirely.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pkg/hub/web.go
Comment on lines +1467 to +1472
// Re-evaluate admin status on every login
newRole := determineUserRole(userInfo.Email, ws.config.AdminEmails)
if user.Role != newRole {
slog.Info("User role changed on login", "email", userInfo.Email, "old_role", user.Role, "new_role", newRole)
user.Role = newRole
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The Web OAuth callback path (handleOAuthCallback) is missing a check to reject suspended users when an existing user logs in. Without this check, a suspended user can still successfully log in via the Web OAuth flow.

We should reject suspended users here, matching the behavior in provisionUser (in pkg/hub/handlers_auth.go).

		// Reject suspended users
		if user.Status == "suspended" {
			slog.Warn("login rejected: user is suspended", "email", userInfo.Email, "user_id", user.ID)
			http.Redirect(w, r, "/login?error=user_suspended", http.StatusFound)
			return
		}

		// Re-evaluate admin status on every login
		newRole := determineUserRole(userInfo.Email, ws.config.AdminEmails)
		if user.Role != newRole {
			slog.Info("User role changed on login", "email", userInfo.Email, "old_role", user.Role, "new_role", newRole)
			user.Role = newRole
		}

Comment thread pkg/hub/handlers_auth.go
Comment on lines +451 to +461
// Re-evaluate admin status on token refresh
role := claims.Role
if newRole := s.getUserRole(claims.Email); role != newRole {
slog.Info("User role changed on token refresh", "email", claims.Email, "old_role", role, "new_role", newRole)
role = newRole
// Persist the role change
if user, err := s.store.GetUserByEmail(r.Context(), claims.Email); err == nil && user.Role != role {
user.Role = role
_ = s.store.UpdateUser(r.Context(), user)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

On token refresh, we should always load the user from the database to verify they still exist and are not suspended. Currently, if a user is suspended or deleted, they can still successfully refresh their token and obtain a new access token as long as their refresh token is valid.

Additionally, please note that the web server's background token refresh path in sessionToBearerMiddleware (in pkg/hub/web.go) still calls ws.userTokenSvc.RefreshTokens(refreshToken) directly, which completely bypasses this new role re-evaluation and status check logic. We should refactor both paths to use a shared helper or ensure the web middleware also performs these checks.

Suggested change
// Re-evaluate admin status on token refresh
role := claims.Role
if newRole := s.getUserRole(claims.Email); role != newRole {
slog.Info("User role changed on token refresh", "email", claims.Email, "old_role", role, "new_role", newRole)
role = newRole
// Persist the role change
if user, err := s.store.GetUserByEmail(r.Context(), claims.Email); err == nil && user.Role != role {
user.Role = role
_ = s.store.UpdateUser(r.Context(), user)
}
}
// Always load the user from the database to verify they still exist and are active
user, err := s.store.GetUserByEmail(r.Context(), claims.Email)
if err != nil {
writeError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "user not found", nil)
return
}
if user.Status == "suspended" {
writeError(w, http.StatusForbidden, "user_suspended", "your account has been suspended", nil)
return
}
// Re-evaluate admin status on token refresh
role := user.Role
if newRole := s.getUserRole(claims.Email); role != newRole {
slog.Info("User role changed on token refresh", "email", claims.Email, "old_role", role, "new_role", newRole)
role = newRole
user.Role = role
_ = s.store.UpdateUser(r.Context(), user)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant