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

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