From e6bbaeffa66923b468cca7d05e22b83a0504a9b3 Mon Sep 17 00:00:00 2001 From: F04C Date: Fri, 8 May 2026 09:04:28 +0800 Subject: [PATCH] feat(authz): redirect deleted accounts to /login detect soft-deleted users during authorization lookup return a dedicated deleted-user result from auth services redirect deleted accounts to /login in the handler update repository, service, and handler tests for the new flow --- handlers/authorize.go | 33 ++++++----- handlers/authorize_test.go | 18 ++++++ repository/permission_repository.go | 12 +++- repository/permission_repository_test.go | 28 ++++++++- services/authorize.go | 9 +++ services/authorize_test.go | 73 +++++++++++++++++------- services/cached_authorization.go | 9 +++ services/cached_authorization_test.go | 58 ++++++++++++++++--- 8 files changed, 193 insertions(+), 47 deletions(-) diff --git a/handlers/authorize.go b/handlers/authorize.go index 4532089..caf990c 100644 --- a/handlers/authorize.go +++ b/handlers/authorize.go @@ -126,24 +126,29 @@ func AuthorizeHandler(w http.ResponseWriter, r *http.Request) { return } - // Return result + writeAuthorizationResponse(w, r, result) +} + +func writeAuthorizationResponse(w http.ResponseWriter, r *http.Request, result *models.AuthorizationResult) { + if result.RedirectRoute != "" { + log.Printf("✗ [Handler] Authorization redirect to %s (reason: %s)", result.RedirectRoute, result.Message) + http.Redirect(w, r, result.RedirectRoute, http.StatusFound) + return + } + + response := map[string]interface{}{ + "allowed": result.Allowed, + "reason": result.Message, + } + if result.Allowed { log.Printf("✓ [Handler] Authorization ALLOWED - Returning 200 OK to client") - // Return sabat matching Authorizationsabat model for client compatibility - response := map[string]interface{}{ - "allowed": result.Allowed, - "reason": result.Message, - } sabat.RespondWithJSON(w, http.StatusOK, response) - } else { - log.Printf("✗ [Handler] Authorization DENIED - Returning 403 Forbidden to client (reason: %s)", result.Message) - // Return sabat matching Authorizationsabat model for client compatibility - response := map[string]interface{}{ - "allowed": result.Allowed, - "reason": result.Message, - } - sabat.RespondWithJSON(w, http.StatusForbidden, response) + return } + + log.Printf("✗ [Handler] Authorization DENIED - Returning 403 Forbidden to client (reason: %s)", result.Message) + sabat.RespondWithJSON(w, http.StatusForbidden, response) } func collectClaimRoles(claims *models.Claims) []int { diff --git a/handlers/authorize_test.go b/handlers/authorize_test.go index cbf885a..1a61126 100644 --- a/handlers/authorize_test.go +++ b/handlers/authorize_test.go @@ -10,6 +10,24 @@ import ( "testing" ) +func TestWriteAuthorizationResponseRedirectsToLogin(t *testing.T) { + req := httptest.NewRequest("POST", AuthCheckEndpoint, nil) + w := httptest.NewRecorder() + + writeAuthorizationResponse(w, req, &models.AuthorizationResult{ + Allowed: false, + RedirectRoute: "/login", + Message: "Account deleted", + }) + + if w.Code != http.StatusFound { + t.Fatalf("Expected status %d, got %d", http.StatusFound, w.Code) + } + if location := w.Header().Get("Location"); location != "/login" { + t.Fatalf("Expected redirect location /login, got %q", location) + } +} + func TestInitAuthService(t *testing.T) { // Test that InitAuthService can be called // It may panic if DB is not available, which is expected behavior diff --git a/repository/permission_repository.go b/repository/permission_repository.go index e75db05..6804321 100644 --- a/repository/permission_repository.go +++ b/repository/permission_repository.go @@ -4,10 +4,13 @@ import ( "authorization/db" "authorization/models" "database/sql" + "errors" "fmt" "log" ) +var ErrUserDeleted = errors.New("user is deleted") + func GetPermissionByResourceActionAndRole(resource, action string, roleID int) (*models.Permission, error) { log.Printf("[Repository] GetPermissionByResourceActionAndRole - resource=%s, action=%s, roleID=%d", resource, action, roleID) @@ -128,13 +131,13 @@ func GetUserByID(userID string) (*models.User, error) { log.Printf("[Repository] GetUserByID - userID=%s", userID) query := ` - SELECT users_id, email_address + SELECT users_id, email_address, role_id, is_deleted FROM uess_user_management.users WHERE users_id = ? ` var user models.User - err := db.DB.QueryRow(query, userID).Scan(&user.UsersID, &user.EmailAddress) + err := db.DB.QueryRow(query, userID).Scan(&user.UsersID, &user.EmailAddress, &user.RoleID, &user.IsDeleted) if err != nil { if err == sql.ErrNoRows { log.Printf("[Repository] ✗ User not found: %s", userID) @@ -144,6 +147,11 @@ func GetUserByID(userID string) (*models.User, error) { return nil, fmt.Errorf("error querying user: %w", err) } + if user.IsDeleted == "1" { + log.Printf("[Repository] ✗ User is deleted: %s", userID) + return nil, ErrUserDeleted + } + log.Printf("[Repository] ✓ User found: UsersID=%s", user.UsersID) return &user, nil } diff --git a/repository/permission_repository_test.go b/repository/permission_repository_test.go index 3165730..ca9346a 100644 --- a/repository/permission_repository_test.go +++ b/repository/permission_repository_test.go @@ -104,8 +104,8 @@ func TestGetUserByIDSuccess(t *testing.T) { mock, cleanup := setupMockDB(t) defer cleanup() - rows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + rows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 7, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). @@ -125,6 +125,9 @@ func TestGetUserByIDSuccess(t *testing.T) { if user.EmailAddress != "john@example.com" { t.Errorf("Expected EmailAddress 'john@example.com', got '%s'", user.EmailAddress) } + if user.RoleID != 7 { + t.Errorf("Expected RoleID 7, got %d", user.RoleID) + } } func TestGetUserByIDNotFound(t *testing.T) { @@ -145,6 +148,27 @@ func TestGetUserByIDNotFound(t *testing.T) { } } +func TestGetUserByIDDeletedUserFilteredOut(t *testing.T) { + mock, cleanup := setupMockDB(t) + defer cleanup() + + rows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("deleted-user", "deleted@example.com", 3, "1") + + mock.ExpectQuery("SELECT users_id, email_address"). + WithArgs("deleted-user"). + WillReturnRows(rows) + + user, err := GetUserByID("deleted-user") + + if !errors.Is(err, ErrUserDeleted) { + t.Errorf("Expected ErrUserDeleted, got %v", err) + } + if user != nil { + t.Error("Expected nil user for deleted user") + } +} + func TestGetAllPermissionsSuccess(t *testing.T) { mock, cleanup := setupMockDB(t) defer cleanup() diff --git a/services/authorize.go b/services/authorize.go index 7310975..ce2f5d1 100644 --- a/services/authorize.go +++ b/services/authorize.go @@ -3,6 +3,7 @@ package services import ( "authorization/models" "authorization/repository" + "errors" "fmt" "log" "time" @@ -15,6 +16,14 @@ func Authorize(ctx *models.AuthorizationContext) (*models.AuthorizationResult, e log.Printf("[AuthZ Step 0] Fetching user details for userID=%s", ctx.UsersID) user, err := repository.GetUserByID(ctx.UsersID) if err != nil { + if errors.Is(err, repository.ErrUserDeleted) { + log.Printf("✗ Deleted user attempted authorization for userID=%s", ctx.UsersID) + return &models.AuthorizationResult{ + Allowed: false, + RedirectRoute: "/login", + Message: "Account deleted", + }, nil + } log.Printf("✗ User not found for userID=%s: %v", ctx.UsersID, err) return &models.AuthorizationResult{ Allowed: false, diff --git a/services/authorize_test.go b/services/authorize_test.go index 128eb7a..e9807d8 100644 --- a/services/authorize_test.go +++ b/services/authorize_test.go @@ -40,15 +40,15 @@ func TestAuthorize_PermissionNotFound(t *testing.T) { } // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). WillReturnRows(userRows) // Mock permission query with role check - mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp"). + mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM uess_user_management.permissions p INNER JOIN uess_user_management.role_permissions rp"). WithArgs("nonexistent", "read", 1). WillReturnError(errors.New("permission not found")) @@ -79,8 +79,8 @@ func TestAuthorize_Success(t *testing.T) { } // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). @@ -90,7 +90,7 @@ func TestAuthorize_Success(t *testing.T) { permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}). AddRow(1, "read_document", "Read document permission", "document", "read") - mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp"). + mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM uess_user_management.permissions p INNER JOIN uess_user_management.role_permissions rp"). WithArgs("document", "read", 1). WillReturnRows(permRows) @@ -98,14 +98,14 @@ func TestAuthorize_Success(t *testing.T) { attrRows := sqlmock.NewRows([]string{"attribute_name", "attribute_value"}). AddRow("department", "engineering") - mock.ExpectQuery("SELECT attribute_name, attribute_value FROM user_attributes WHERE users_id = \\?"). - WithArgs("user123"). + mock.ExpectQuery("SELECT attribute_name, attribute_value FROM uess_user_management.user_attributes WHERE users_id = \\?"). + WithArgs("user123", "user123"). WillReturnRows(attrRows) // Mock policy attributes query (empty for this test) policyRows := sqlmock.NewRows([]string{"id", "attribute_name", "attribute_type", "comparison", "attribute_value", "permission_id"}) - mock.ExpectQuery("SELECT id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM policy_attributes WHERE permission_id = \\?"). + mock.ExpectQuery("SELECT id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM uess_user_management.policy_attributes WHERE permission_id = \\?"). WithArgs(1). WillReturnRows(policyRows) @@ -136,8 +136,8 @@ func TestAuthorize_UserAttributesError(t *testing.T) { } // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). @@ -147,13 +147,13 @@ func TestAuthorize_UserAttributesError(t *testing.T) { permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}). AddRow(1, "read_document", "Read document permission", "document", "read") - mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp"). + mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM uess_user_management.permissions p INNER JOIN uess_user_management.role_permissions rp"). WithArgs("document", "read", 1). WillReturnRows(permRows) // Mock user attributes query with error - mock.ExpectQuery("SELECT attribute_name, attribute_value FROM user_attributes WHERE users_id = \\?"). - WithArgs("user123"). + mock.ExpectQuery("SELECT attribute_name, attribute_value FROM uess_user_management.user_attributes WHERE users_id = \\?"). + WithArgs("user123", "user123"). WillReturnError(errors.New("database error")) result, err := Authorize(ctx) @@ -180,8 +180,8 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) { } // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). @@ -191,7 +191,7 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) { permRows := sqlmock.NewRows([]string{"id", "permission_name", "description", "resource", "action"}). AddRow(1, "read_document", "Read document permission", "document", "read") - mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp"). + mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM uess_user_management.permissions p INNER JOIN uess_user_management.role_permissions rp"). WithArgs("document", "read", 1). WillReturnRows(permRows) @@ -199,12 +199,12 @@ func TestAuthorize_PolicyAttributesError(t *testing.T) { attrRows := sqlmock.NewRows([]string{"attribute_name", "attribute_value"}). AddRow("department", "engineering") - mock.ExpectQuery("SELECT attribute_name, attribute_value FROM user_attributes WHERE users_id = \\?"). - WithArgs("user123"). + mock.ExpectQuery("SELECT attribute_name, attribute_value FROM uess_user_management.user_attributes WHERE users_id = \\?"). + WithArgs("user123", "user123"). WillReturnRows(attrRows) // Mock policy attributes query with error - mock.ExpectQuery("SELECT id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM policy_attributes WHERE permission_id = \\?"). + mock.ExpectQuery("SELECT id, attribute_name, attribute_type, comparison, attribute_value, permission_id FROM uess_user_management.policy_attributes WHERE permission_id = \\?"). WithArgs(1). WillReturnError(errors.New("database error")) @@ -252,3 +252,36 @@ func TestGetRoleCandidates_Priority(t *testing.T) { } }) } + +func TestAuthorize_DeletedUserRedirectsToLogin(t *testing.T) { + mock, cleanup := setupMockDB(t) + defer cleanup() + + ctx := &models.AuthorizationContext{ + UsersID: "deleted-user", + Resource: "document", + Action: "read", + RoleID: 1, + ResourceData: make(map[string]string), + Environment: make(map[string]string), + } + + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("deleted-user", "deleted@example.com", 1, "1") + + mock.ExpectQuery("SELECT users_id, email_address, role_id, is_deleted"). + WithArgs("deleted-user"). + WillReturnRows(userRows) + + result, err := Authorize(ctx) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result.Allowed { + t.Error("Expected access denied") + } + if result.RedirectRoute != "/login" { + t.Errorf("Expected redirect to /login, got %q", result.RedirectRoute) + } +} diff --git a/services/cached_authorization.go b/services/cached_authorization.go index df64200..d670015 100644 --- a/services/cached_authorization.go +++ b/services/cached_authorization.go @@ -6,6 +6,7 @@ import ( "authorization/repository" "context" "encoding/json" + "errors" "fmt" "log" "sync" @@ -240,6 +241,14 @@ func AuthorizeWithCache(s *models.CachedAuthorizationService, ctx *models.Author log.Printf("[AuthZ Step 0] Fetching user details for userID=%s", ctx.UsersID) user, err := repository.GetUserByID(ctx.UsersID) if err != nil { + if errors.Is(err, repository.ErrUserDeleted) { + log.Printf("✗ Deleted user attempted authorization for userID=%s", ctx.UsersID) + return &models.AuthorizationResult{ + Allowed: false, + RedirectRoute: "/login", + Message: "Account deleted", + }, nil + } log.Printf("✗ User not found for userID=%s: %v", ctx.UsersID, err) return &models.AuthorizationResult{ Allowed: false, diff --git a/services/cached_authorization_test.go b/services/cached_authorization_test.go index 6c135d3..114164f 100644 --- a/services/cached_authorization_test.go +++ b/services/cached_authorization_test.go @@ -104,8 +104,8 @@ func TestGetCachedUserAttributes_CacheMiss(t *testing.T) { attrRows := sqlmock.NewRows([]string{"attribute_name", "attribute_value"}). AddRow("department", "engineering") - mock.ExpectQuery("SELECT attribute_name, attribute_value FROM user_attributes WHERE users_id = \\?"). - WithArgs("user123"). + mock.ExpectQuery("SELECT attribute_name, attribute_value FROM uess_user_management.user_attributes WHERE users_id = \\?"). + WithArgs("user123", "user123"). WillReturnRows(attrRows) attrs, err := getCachedUserAttributes(service, "user123") @@ -219,8 +219,8 @@ func TestAuthorizeWithCache_Success(t *testing.T) { service.PolicyCache[1] = []models.PolicyAttribute{} // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). @@ -230,8 +230,8 @@ func TestAuthorizeWithCache_Success(t *testing.T) { attrRows := sqlmock.NewRows([]string{"attribute_name", "attribute_value"}). AddRow("department", "engineering") - mock.ExpectQuery("SELECT attribute_name, attribute_value FROM user_attributes WHERE users_id = \\?"). - WithArgs("user123"). + mock.ExpectQuery("SELECT attribute_name, attribute_value FROM uess_user_management.user_attributes WHERE users_id = \\?"). + WithArgs("user123", "user123"). WillReturnRows(attrRows) ctx := &models.AuthorizationContext{ @@ -268,18 +268,19 @@ func TestAuthorizeWithCache_PermissionNotFound(t *testing.T) { UsersID: "user123", Resource: "nonexistent", Action: "read", + RoleID: 1, } // Mock user query - userRows := sqlmock.NewRows([]string{"users_id", "email_address"}). - AddRow("user123", "john@example.com") + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("user123", "john@example.com", 1, "0") mock.ExpectQuery("SELECT users_id, email_address"). WithArgs("user123"). WillReturnRows(userRows) // Permission not in cache, so will query DB and fail - mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM permissions p INNER JOIN role_permissions rp"). + mock.ExpectQuery("SELECT p.permissions_id, p.permission_name, p.description, p.resource, p.action FROM uess_user_management.permissions p INNER JOIN uess_user_management.role_permissions rp"). WithArgs("nonexistent", "read", 1). WillReturnError(errors.New("permission not found")) @@ -296,6 +297,45 @@ func TestAuthorizeWithCache_PermissionNotFound(t *testing.T) { } } +func TestAuthorizeWithCache_DeletedUserRedirectsToLogin(t *testing.T) { + mock, cleanup := setupMockDBForCached(t) + defer cleanup() + + service := &models.CachedAuthorizationService{ + PermissionCache: make(map[string]*models.Permission), + PolicyCache: make(map[int][]models.PolicyAttribute), + UserAttrCache: make(map[string]map[string]string), + CacheMutex: &sync.RWMutex{}, + UserAttrMutex: &sync.RWMutex{}, + } + + userRows := sqlmock.NewRows([]string{"users_id", "email_address", "role_id", "is_deleted"}). + AddRow("deleted-user", "deleted@example.com", 1, "1") + + mock.ExpectQuery("SELECT users_id, email_address, role_id, is_deleted"). + WithArgs("deleted-user"). + WillReturnRows(userRows) + + ctx := &models.AuthorizationContext{ + UsersID: "deleted-user", + Resource: "document", + Action: "read", + RoleID: 1, + } + + result, err := AuthorizeWithCache(service, ctx) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result.Allowed { + t.Error("Expected access denied") + } + if result.RedirectRoute != "/login" { + t.Errorf("Expected redirect to /login, got %q", result.RedirectRoute) + } +} + func TestInvalidateUserCache(t *testing.T) { service := &models.CachedAuthorizationService{ UserAttrCache: make(map[string]map[string]string),