feat: RBAC Phase 1 — consolidate user roles into 4-value enum
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

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:
2026-02-19 22:44:29 +01:00
parent ef21d47533
commit 1dcb0e6c33
67 changed files with 874 additions and 616 deletions

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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(),