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
51 changes: 43 additions & 8 deletions internal/app/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,13 @@ type LoginResult struct {
RefreshToken string // Global refresh token (no tenant context)
ExpiresAt time.Time
SessionID string
Tenants []TenantMembershipInfo // List of tenants user belongs to
Tenants []TenantMembershipInfo // Active tenants user can access
// SuspendedTenants lists tenants where this user has a suspended
// membership. The user has zero access to these tenants — the field
// exists purely so the UI can show "your access to {name} is
// suspended" instead of bouncing the user to /onboarding/create-team
// when they have no active memberships.
SuspendedTenants []TenantMembershipInfo
}

// Login authenticates a user and creates a session.
Expand Down Expand Up @@ -479,6 +485,26 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput) (*LoginResult
})
}

// Also fetch suspended memberships so the client can show a clear
// "your access to {tenant} is suspended" message instead of routing
// the user into the create-team flow with no explanation. This is a
// best-effort lookup — failure does not break login.
var suspendedInfos []TenantMembershipInfo
if suspended, serr := s.tenantRepo.GetUserSuspendedMemberships(ctx, u.ID()); serr == nil {
suspendedInfos = make([]TenantMembershipInfo, 0, len(suspended))
for _, m := range suspended {
suspendedInfos = append(suspendedInfos, TenantMembershipInfo{
TenantID: m.TenantID,
TenantSlug: m.TenantSlug,
TenantName: m.TenantName,
Role: m.Role,
})
}
} else {
s.logger.Warn("failed to load suspended memberships at login",
"user_id", u.ID().String(), "error", serr)
}

// Generate GLOBAL refresh token (no tenant context)
refreshTokenStr, refreshExpiresAt, err := s.tokenGenerator.GenerateGlobalRefreshToken(
u.ID().String(),
Expand Down Expand Up @@ -538,11 +564,12 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput) (*LoginResult
}

return &LoginResult{
User: u,
RefreshToken: refreshTokenStr,
ExpiresAt: refreshExpiresAt,
SessionID: sess.ID().String(),
Tenants: tenantInfos,
User: u,
RefreshToken: refreshTokenStr,
ExpiresAt: refreshExpiresAt,
SessionID: sess.ID().String(),
Tenants: tenantInfos,
SuspendedTenants: suspendedInfos,
}, nil
}

Expand Down Expand Up @@ -1351,9 +1378,17 @@ func (s *AuthService) AcceptInvitationWithRefreshToken(ctx context.Context, inpu
}
}

// Check if user is already a member
_, err = s.tenantRepo.GetMembership(ctx, u.ID(), invitation.TenantID())
// Check if user is already a member. A suspended membership cannot be
// bypassed via invitation accept — that would silently erase the
// suspension audit trail. The admin must reactivate via Members page.
existingMembership, err := s.tenantRepo.GetMembership(ctx, u.ID(), invitation.TenantID())
if err == nil {
if existingMembership.IsSuspended() {
return nil, fmt.Errorf(
"%w: your access to this team is suspended — please contact an administrator to be reactivated",
shared.ErrValidation,
)
}
return nil, fmt.Errorf("%w: you are already a member of this team", shared.ErrValidation)
}
if !errors.Is(err, shared.ErrNotFound) {
Expand Down
46 changes: 35 additions & 11 deletions internal/app/tenant_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,17 @@ func (s *TenantService) AddMember(ctx context.Context, tenantID string, input Ad
return nil, fmt.Errorf("%w: invalid role", shared.ErrValidation)
}

// Check if user is already a member
_, err = s.repo.GetMembership(ctx, input.UserID, parsedTenantID)
// Check if user is already a member. A suspended membership blocks
// re-add: the admin must reactivate the existing row instead of
// creating a duplicate that loses the suspension audit trail.
existing, err := s.repo.GetMembership(ctx, input.UserID, parsedTenantID)
if err == nil {
if existing.IsSuspended() {
return nil, fmt.Errorf(
"%w: this user has a suspended membership in this tenant — reactivate them via the Members page instead",
shared.ErrValidation,
)
}
return nil, fmt.Errorf("%w: user is already a member", shared.ErrValidation)
}
if !errors.Is(err, shared.ErrNotFound) {
Expand Down Expand Up @@ -504,11 +512,10 @@ func (s *TenantService) SuspendMember(ctx context.Context, membershipID string,
s.logger.Info("member suspended", "membership_id", membershipID, "user_id", userID)

actx.TenantID = tenantID
event := NewSuccessEvent(audit.ActionMemberRemoved, audit.ResourceTypeMembership, membershipID).
event := NewSuccessEvent(audit.ActionMemberSuspended, audit.ResourceTypeMembership, membershipID).
WithSeverity(audit.SeverityHigh).
WithMessage("Member suspended").
WithMetadata("user_id", userID).
WithMetadata("action", "suspend")
WithMetadata("user_id", userID)
s.logAudit(ctx, actx, event)

return nil
Expand Down Expand Up @@ -540,11 +547,10 @@ func (s *TenantService) ReactivateMember(ctx context.Context, membershipID strin
s.logger.Info("member reactivated", "membership_id", membershipID, "user_id", userID)

actx.TenantID = tenantID
event := NewSuccessEvent(audit.ActionMemberRemoved, audit.ResourceTypeMembership, membershipID).
event := NewSuccessEvent(audit.ActionMemberReactivated, audit.ResourceTypeMembership, membershipID).
WithSeverity(audit.SeverityMedium).
WithMessage("Member reactivated").
WithMetadata("user_id", userID).
WithMetadata("action", "reactivate")
WithMetadata("user_id", userID)
s.logAudit(ctx, actx, event)

return nil
Expand Down Expand Up @@ -693,9 +699,18 @@ func (s *TenantService) CreateInvitation(ctx context.Context, tenantID string, i
return nil, fmt.Errorf("failed to check existing invitation: %w", err)
}

// Check if user is already a member of this tenant
// Check if user is already a member of this tenant. A suspended
// membership counts as "already a member" — the admin must reactivate
// them via the Members page rather than sending a new invitation, so
// the suspend audit trail and any compliance evidence stay intact.
existingMember, err := s.repo.GetMemberByEmail(ctx, parsedID, input.Email)
if err == nil && existingMember != nil {
if existingMember.Status == string(tenant.MemberStatusSuspended) {
return nil, fmt.Errorf(
"%w: this user has a suspended membership in this tenant — reactivate them via the Members page instead of sending a new invitation",
shared.ErrValidation,
)
}
return nil, fmt.Errorf("%w: user with this email is already a member of this team", shared.ErrValidation)
}
if err != nil && !errors.Is(err, shared.ErrNotFound) {
Expand Down Expand Up @@ -794,9 +809,18 @@ func (s *TenantService) AcceptInvitation(ctx context.Context, token string, user
}
}

// Check if user is already a member
_, err = s.repo.GetMembership(ctx, userID, invitation.TenantID())
// Check if user is already a member. If they have a suspended
// membership the admin must reactivate it via the Members page —
// accepting an invitation cannot bypass an active suspension because
// that would silently erase the audit trail.
existingMembership, err := s.repo.GetMembership(ctx, userID, invitation.TenantID())
if err == nil {
if existingMembership.IsSuspended() {
return nil, fmt.Errorf(
"%w: your access to this team is suspended — please contact an administrator to be reactivated",
shared.ErrValidation,
)
}
return nil, fmt.Errorf("%w: you are already a member of this team", shared.ErrValidation)
}
if !errors.Is(err, shared.ErrNotFound) {
Expand Down
65 changes: 5 additions & 60 deletions internal/app/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,66 +219,11 @@ func (s *UserService) UpdatePreferences(ctx context.Context, userID string, pref
return u, nil
}

// SuspendUser suspends a user account.
// This also revokes all active sessions to immediately block access.
func (s *UserService) SuspendUser(ctx context.Context, userID string) (*user.User, error) {
parsedID, err := shared.IDFromString(userID)
if err != nil {
return nil, fmt.Errorf("%w: invalid id format", shared.ErrValidation)
}

u, err := s.repo.GetByID(ctx, parsedID)
if err != nil {
return nil, err
}

if err := u.Suspend(); err != nil {
return nil, err
}

if err := s.repo.Update(ctx, u); err != nil {
return nil, fmt.Errorf("failed to suspend user: %w", err)
}

// Revoke all sessions to immediately block access
// This also triggers permission cache invalidation via SessionService
if s.sessionService != nil {
if err := s.sessionService.RevokeAllSessions(ctx, userID, ""); err != nil {
s.logger.Warn("failed to revoke sessions on suspend",
"user_id", userID,
"error", err,
)
// Don't fail the suspend operation, just log the warning
}
}

s.logger.Info("user suspended", "user_id", userID)
return u, nil
}

// ActivateUser activates a user account.
func (s *UserService) ActivateUser(ctx context.Context, userID string) (*user.User, error) {
parsedID, err := shared.IDFromString(userID)
if err != nil {
return nil, fmt.Errorf("%w: invalid id format", shared.ErrValidation)
}

u, err := s.repo.GetByID(ctx, parsedID)
if err != nil {
return nil, err
}

if err := u.Activate(); err != nil {
return nil, err
}

if err := s.repo.Update(ctx, u); err != nil {
return nil, fmt.Errorf("failed to activate user: %w", err)
}

s.logger.Info("user activated", "user_id", userID)
return u, nil
}
// Note: SuspendUser / ActivateUser were removed. They were never wired
// into any handler or route, and member access is managed at the
// membership level via TenantService.SuspendMember / ReactivateMember.
// See pkg/domain/user/entity.go for the rationale on keeping the
// status column itself.

// GetUsersByIDs retrieves multiple users by their IDs (string format).
func (s *UserService) GetUsersByIDs(ctx context.Context, userIDs []string) ([]*user.User, error) {
Expand Down
24 changes: 23 additions & 1 deletion internal/infra/http/handler/local_auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ type LoginResponse struct {
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
Tenants []TenantInfo `json:"tenants"`
// SuspendedTenants is non-empty when the user has memberships that
// are currently suspended. The client uses this to show a clear
// "your access to X is suspended" notice instead of bouncing the
// user to /onboarding/create-team. Suspended tenants are NOT
// accessible — the user cannot pick one and exchange a token.
SuspendedTenants []TenantInfo `json:"suspended_tenants,omitempty"`
}

// UserInfo contains basic user information.
Expand Down Expand Up @@ -236,6 +242,21 @@ func (h *LocalAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}
}

// Convert suspended memberships (may be nil — that's fine, omitempty
// keeps the response clean for the typical case).
var suspendedTenants []TenantInfo
if len(result.SuspendedTenants) > 0 {
suspendedTenants = make([]TenantInfo, len(result.SuspendedTenants))
for i, t := range result.SuspendedTenants {
suspendedTenants[i] = TenantInfo{
ID: t.TenantID,
Slug: t.TenantSlug,
Name: t.TenantName,
Role: t.Role,
}
}
}

// Calculate expires_in in seconds (for refresh token)
expiresIn := int64(h.authConfig.RefreshTokenDuration.Seconds())

Expand All @@ -260,7 +281,8 @@ func (h *LocalAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
Email: result.User.Email(),
Name: result.User.Name(),
},
Tenants: tenants,
Tenants: tenants,
SuspendedTenants: suspendedTenants,
}

w.Header().Set("Content-Type", "application/json")
Expand Down
36 changes: 10 additions & 26 deletions internal/infra/http/middleware/user_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,12 @@ func UserSync(userService *app.UserService, log *logger.Logger) func(http.Handle
"email", localUser.Email(),
)

// Check if user is suspended
if localUser.IsSuspended() {
// Check the platform-level user status. The OSS product has
// no UI to set this; only direct DB intervention can. We log
// the access attempt but do not block here — the auth flow
// already rejects suspended users at login. This is a warn
// for forensic visibility.
if localUser.Status() == user.StatusSuspended {
log.Warn("suspended user attempted access",
"user_id", localUser.ID().String(),
"keycloak_id", keycloakID,
Expand Down Expand Up @@ -188,27 +192,7 @@ func RequireLocalUser() func(http.Handler) http.Handler {
}
}

// RequireActiveUser ensures the local user is active (not suspended).
func RequireActiveUser() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
localUser := GetLocalUser(r.Context())
if localUser == nil {
http.Error(w, "User not found", http.StatusInternalServerError)
return
}

if localUser.IsSuspended() {
http.Error(w, "User account is suspended", http.StatusForbidden)
return
}

if !localUser.IsActive() {
http.Error(w, "User account is inactive", http.StatusForbidden)
return
}

next.ServeHTTP(w, r)
})
}
}
// Note: RequireActiveUser middleware was removed. It was never wired
// into any route, and tenant-scoped routes already enforce access via
// RequireMembership (which checks membership.IsSuspended). Platform-
// level user suspension has no UI in the OSS product.
42 changes: 42 additions & 0 deletions internal/infra/postgres/tenant_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,48 @@ func (r *TenantRepository) GetMemberStats(ctx context.Context, tenantID shared.I
}, nil
}

// GetUserSuspendedMemberships returns the suspended memberships for a user.
// Mirrors GetUserMemberships but with the inverted status filter. Used by
// the login flow so the UI can surface "your access to {tenant} is suspended"
// instead of routing the user to onboarding when they have only-suspended
// memberships left.
func (r *TenantRepository) GetUserSuspendedMemberships(ctx context.Context, userID shared.ID) ([]tenant.UserMembership, error) {
query := `
SELECT
t.id,
t.slug,
t.name,
COALESCE(ver.role, m.role) as effective_role
FROM tenant_members m
INNER JOIN tenants t ON t.id = m.tenant_id
LEFT JOIN v_user_effective_role ver ON ver.user_id = m.user_id AND ver.tenant_id = m.tenant_id
WHERE m.user_id = $1
AND m.status = 'suspended'
ORDER BY m.suspended_at DESC NULLS LAST, m.joined_at DESC
`

rows, err := r.db.QueryContext(ctx, query, userID.String())
if err != nil {
return nil, fmt.Errorf("failed to get suspended memberships: %w", err)
}
defer rows.Close()

var memberships []tenant.UserMembership
for rows.Next() {
var m tenant.UserMembership
if err := rows.Scan(&m.TenantID, &m.TenantSlug, &m.TenantName, &m.Role); err != nil {
return nil, fmt.Errorf("failed to scan suspended membership: %w", err)
}
memberships = append(memberships, m)
}

if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate suspended memberships: %w", err)
}

return memberships, nil
}

// GetUserMemberships returns lightweight membership data for JWT tokens.
// Uses v_user_effective_role view to get the highest-priority role from user_roles table.
// SECURITY: Suspended memberships are excluded so suspended users cannot exchange
Expand Down
Loading
Loading