feat: RBAC Phase 1 — consolidate user roles into 4-value enum
Some checks failed
Some checks failed
Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean) into a single 4-value UserRole enum: super_admin, platform_admin, merchant_owner, store_member. Drop stale StoreUser.user_type column. Fix role="user" bug in merchant creation. Key changes: - Expand UserRole enum from 2 to 4 values with computed properties (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user) - Add Alembic migration (tenancy_003) for data migration + column drops - Remove is_super_admin from JWT token payload - Update all auth dependencies, services, routes, templates, JS, and tests - Update all RBAC documentation 66 files changed, 1219 unit tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -211,7 +211,7 @@ class TestValidateCustomerToken:
|
||||
email=f"owner_{uid}@test.com",
|
||||
username=f"owner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -353,7 +353,7 @@ class TestGetCurrentAdminFromCookieOrHeader:
|
||||
|
||||
result = get_current_admin_from_cookie_or_header(request, creds, None, db)
|
||||
assert result.id == test_admin.id
|
||||
assert result.role == "admin"
|
||||
assert result.is_admin is True
|
||||
|
||||
def test_valid_admin_via_cookie(self, db, auth_manager, test_admin):
|
||||
"""Admin user with valid cookie token returns UserContext."""
|
||||
@@ -394,7 +394,7 @@ class TestGetCurrentAdminApi:
|
||||
|
||||
result = get_current_admin_api(creds, db)
|
||||
assert result.id == test_admin.id
|
||||
assert result.role == "admin"
|
||||
assert result.is_admin is True
|
||||
|
||||
def test_rejects_non_admin(self, db, auth_manager, test_store_user):
|
||||
"""Non-admin user rejected with AdminRequiredException."""
|
||||
@@ -474,7 +474,7 @@ class TestGetCurrentStoreFromCookieOrHeader:
|
||||
|
||||
result = get_current_store_from_cookie_or_header(request, creds, None, db)
|
||||
assert result.id == test_store_user.id
|
||||
assert result.role == "store"
|
||||
assert result.is_store_user is True
|
||||
|
||||
def test_valid_store_user_via_cookie(self, db, auth_manager, test_store_user):
|
||||
"""Store user with valid cookie token returns UserContext."""
|
||||
@@ -626,7 +626,7 @@ def _create_merchant_owner(db, auth_manager):
|
||||
email=f"merchant_{uid}@example.com",
|
||||
username=f"merchant_{uid}",
|
||||
hashed_password=auth_manager.hash_password("testpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
@@ -786,7 +786,7 @@ class TestGetCurrentCustomerFromCookieOrHeader:
|
||||
email=f"csowner_{uid}@test.com",
|
||||
username=f"csowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -879,7 +879,7 @@ class TestGetCurrentCustomerApi:
|
||||
email=f"caowner_{uid}@test.com",
|
||||
username=f"caowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -962,7 +962,7 @@ class TestGetCurrentCustomerOptional:
|
||||
email=f"coowner_{uid}@test.com",
|
||||
username=f"coowner_{uid}",
|
||||
hashed_password="not_a_real_hash",
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
@@ -1293,7 +1293,7 @@ class TestGetUserStore:
|
||||
email=f"other_{uid2}@example.com",
|
||||
username=f"other_{uid2}",
|
||||
hashed_password=auth_manager.hash_password("testpass123"),
|
||||
role="store",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is True
|
||||
assert payload["role"] == "super_admin"
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_platform_admin_with_platforms(self, db, auth_manager, test_platform_admin):
|
||||
@@ -155,7 +155,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert payload["role"] == "platform_admin"
|
||||
assert payload["accessible_platforms"] == [1, 2, 3]
|
||||
|
||||
def test_platform_admin_without_platforms(self, db, auth_manager, test_platform_admin):
|
||||
@@ -166,7 +166,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert payload["is_super_admin"] is False
|
||||
assert payload["role"] == "platform_admin"
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
def test_store_context(self, auth_manager, test_user):
|
||||
@@ -223,7 +223,7 @@ class TestCreateAccessToken:
|
||||
payload = jose_jwt.decode(
|
||||
result["access_token"], auth_manager.secret_key, algorithms=["HS256"]
|
||||
)
|
||||
assert "is_super_admin" not in payload
|
||||
assert payload["role"] not in ("super_admin", "platform_admin")
|
||||
assert "accessible_platforms" not in payload
|
||||
|
||||
|
||||
@@ -271,10 +271,10 @@ class TestVerifyToken:
|
||||
|
||||
def test_valid_admin_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(is_super_admin=True), auth_manager.secret_key
|
||||
self._base_payload(role="super_admin"), auth_manager.secret_key
|
||||
)
|
||||
data = auth_manager.verify_token(token)
|
||||
assert data["is_super_admin"] is True
|
||||
assert data["role"] == "super_admin"
|
||||
|
||||
def test_valid_platform_token(self, auth_manager):
|
||||
token = self._encode(
|
||||
@@ -288,7 +288,7 @@ class TestVerifyToken:
|
||||
def test_valid_all_claims(self, auth_manager):
|
||||
token = self._encode(
|
||||
self._base_payload(
|
||||
is_super_admin=False,
|
||||
role="platform_admin",
|
||||
accessible_platforms=[1, 2],
|
||||
platform_id=1,
|
||||
platform_code="us",
|
||||
@@ -383,7 +383,7 @@ class TestGetCurrentUser:
|
||||
token_data = auth_manager.create_access_token(test_super_admin)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert user.token_is_super_admin is True
|
||||
assert user.is_super_admin is True
|
||||
|
||||
def test_attaches_platform_attrs(self, db, auth_manager, test_admin):
|
||||
token_data = auth_manager.create_access_token(
|
||||
@@ -408,7 +408,7 @@ class TestGetCurrentUser:
|
||||
token_data = auth_manager.create_access_token(test_user)
|
||||
creds = self._make_credentials(token_data["access_token"])
|
||||
user = auth_manager.get_current_user(db, creds)
|
||||
assert not hasattr(user, "token_is_super_admin")
|
||||
assert user.is_super_admin is False
|
||||
assert not hasattr(user, "token_platform_id")
|
||||
assert not hasattr(user, "token_store_id")
|
||||
|
||||
@@ -468,38 +468,50 @@ class TestRequireRole:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireAdmin:
|
||||
"""Test require_admin method."""
|
||||
"""Test require_admin method (accepts super_admin and platform_admin)."""
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
def test_super_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
user.is_admin = True
|
||||
result = auth_manager.require_admin(user)
|
||||
assert result is user
|
||||
|
||||
def test_platform_admin_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.is_admin = True
|
||||
result = auth_manager.require_admin(user)
|
||||
assert result is user
|
||||
|
||||
def test_non_admin_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "user"
|
||||
user.is_admin = False
|
||||
with pytest.raises(AdminRequiredException):
|
||||
auth_manager.require_admin(user)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireStore:
|
||||
"""Test require_store method (accepts store and admin roles)."""
|
||||
"""Test require_store method (accepts merchant_owner and store_member)."""
|
||||
|
||||
def test_store_accepted(self, auth_manager):
|
||||
def test_merchant_owner_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
user.is_store_user = True
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_admin_accepted(self, auth_manager):
|
||||
def test_store_member_accepted(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "admin"
|
||||
user.is_store_user = True
|
||||
assert auth_manager.require_store(user) is user
|
||||
|
||||
def test_admin_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.is_store_user = False
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_store(user)
|
||||
|
||||
def test_customer_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "customer"
|
||||
user.is_store_user = False
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_store(user)
|
||||
|
||||
@@ -520,7 +532,7 @@ class TestRequireCustomer:
|
||||
|
||||
def test_store_rejected(self, auth_manager):
|
||||
user = Mock(spec=User)
|
||||
user.role = "store"
|
||||
user.role = "merchant_owner"
|
||||
with pytest.raises(InsufficientPermissionsException):
|
||||
auth_manager.require_customer(user)
|
||||
|
||||
@@ -535,7 +547,7 @@ class TestCreateDefaultAdminUser:
|
||||
def test_creates_admin_when_none_exists(self, db, auth_manager):
|
||||
user = auth_manager.create_default_admin_user(db)
|
||||
assert user.username == "admin"
|
||||
assert user.role == "admin"
|
||||
assert user.role == "super_admin"
|
||||
assert user.is_super_admin is True
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@@ -65,19 +65,19 @@ class TestUserCreateSchema:
|
||||
password="securepass", # noqa: SEC001
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role="admin",
|
||||
role="super_admin",
|
||||
)
|
||||
assert user.email == "admin@example.com"
|
||||
assert user.role == "admin"
|
||||
assert user.role == "super_admin"
|
||||
|
||||
def test_default_role_is_store(self):
|
||||
"""Test default role is store."""
|
||||
def test_default_role_is_store_member(self):
|
||||
"""Test default role is store_member."""
|
||||
user = UserCreate(
|
||||
email="store@example.com",
|
||||
username="storeuser",
|
||||
password="securepass", # noqa: SEC001
|
||||
)
|
||||
assert user.role == "store"
|
||||
assert user.role == "store_member"
|
||||
|
||||
def test_invalid_role(self):
|
||||
"""Test invalid role raises ValidationError."""
|
||||
@@ -135,10 +135,10 @@ class TestUserUpdateSchema:
|
||||
|
||||
def test_valid_role_update(self):
|
||||
"""Test valid role values."""
|
||||
admin_update = UserUpdate(role="admin")
|
||||
store_update = UserUpdate(role="store")
|
||||
assert admin_update.role == "admin"
|
||||
assert store_update.role == "store"
|
||||
admin_update = UserUpdate(role="super_admin")
|
||||
store_update = UserUpdate(role="merchant_owner")
|
||||
assert admin_update.role == "super_admin"
|
||||
assert store_update.role == "merchant_owner"
|
||||
|
||||
def test_empty_update(self):
|
||||
"""Test empty update is valid (all fields optional)."""
|
||||
@@ -159,7 +159,7 @@ class TestUserResponseSchema:
|
||||
"id": 1,
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"role": "store",
|
||||
"role": "merchant_owner",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
@@ -177,7 +177,7 @@ class TestUserResponseSchema:
|
||||
"id": 1,
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"role": "store",
|
||||
"role": "merchant_owner",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
|
||||
Reference in New Issue
Block a user