feat: implement complete RBAC access control with tests
Some checks failed
CI / pytest (push) Failing after 45m29s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 9s

Add 4-layer access control stack (subscription → module → menu → permissions):
- P1: Wire requires_permission into menu sidebar filtering
- P2: Expose window.USER_PERMISSIONS for Alpine.js client-side gating
- P3: Add page-level permission guards on store routes
- P4: Role CRUD API endpoints and role editor UI
- P5: Audit trail for all role/permission changes

Includes unit tests (menu permission filtering, role CRUD service) and
integration tests (role API endpoints). All 404 core+tenancy tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:26:59 +01:00
parent 962862ccc1
commit cb3bc3c118
29 changed files with 1850 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ import pytest
from app.modules.tenancy.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
InvalidRoleException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, User
@@ -417,3 +418,313 @@ class TestStoreTeamServiceGetRoles:
for role in roles:
assert "permissions" in role
assert isinstance(role["permissions"], list)
# =============================================================================
# CUSTOM ROLE CRUD TESTS
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceCreateCustomRole:
"""Test suite for creating custom roles."""
def test_create_custom_role_success(self, db, team_store, test_user):
"""Test creating a custom role with valid permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="content_editor",
permissions=["products.view", "products.edit"],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "content_editor"
assert role.store_id == team_store.id
assert "products.view" in role.permissions
assert "products.edit" in role.permissions
def test_create_role_with_preset_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with a preset name raises ValueError."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_preset_name_case_insensitive(self, db, team_store, test_user):
"""Test preset name check is case-insensitive."""
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="Manager",
permissions=["products.view"],
actor_user_id=test_user.id,
)
def test_create_duplicate_role_name_raises_error(self, db, team_store, test_user):
"""Test creating a role with duplicate name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="editor",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
def test_create_role_with_invalid_permissions_raises_error(self, db, team_store, test_user):
"""Test creating a role with invalid permission IDs raises ValueError."""
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="invalid_role",
permissions=["completely.fake.permission"],
actor_user_id=test_user.id,
)
def test_create_role_with_empty_permissions(self, db, team_store, test_user):
"""Test creating a role with empty permissions list."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="observer",
permissions=[],
actor_user_id=test_user.id,
)
db.commit()
assert role is not None
assert role.name == "observer"
assert role.permissions == []
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceUpdateCustomRole:
"""Test suite for updating roles."""
def test_update_role_name(self, db, team_store, test_user):
"""Test updating a role's name."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="old_name",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="new_name",
actor_user_id=test_user.id,
)
db.commit()
assert updated.name == "new_name"
def test_update_role_permissions(self, db, team_store, test_user):
"""Test updating a role's permissions."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="perm_test",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
updated = store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["products.view", "products.edit", "orders.view"],
actor_user_id=test_user.id,
)
db.commit()
assert "products.edit" in updated.permissions
assert "orders.view" in updated.permissions
def test_update_role_not_found_raises_error(self, db, team_store):
"""Test updating a non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=99999,
name="new_name",
)
def test_update_role_rename_to_preset_raises_error(self, db, team_store, test_user):
"""Test renaming to a preset name raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="custom_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="preset name"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
name="staff",
)
def test_update_role_with_invalid_permissions(self, db, team_store, test_user):
"""Test updating with invalid permissions raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_update",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="Invalid permission IDs"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role.id,
permissions=["nonexistent.perm"],
)
def test_update_role_duplicate_name_raises_error(self, db, team_store, test_user):
"""Test renaming to an existing role name raises ValueError."""
store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_a",
permissions=["products.view"],
actor_user_id=test_user.id,
)
role_b = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="role_b",
permissions=["orders.view"],
actor_user_id=test_user.id,
)
db.flush()
with pytest.raises(InvalidRoleException, match="already exists"):
store_team_service.update_role(
db=db,
store_id=team_store.id,
role_id=role_b.id,
name="role_a",
)
@pytest.mark.unit
@pytest.mark.tenancy
class TestStoreTeamServiceDeleteRole:
"""Test suite for deleting roles."""
def test_delete_custom_role_success(self, db, team_store, test_user):
"""Test deleting a custom role."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="to_delete",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
role_id = role.id
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role_id,
actor_user_id=test_user.id,
)
db.commit()
# Verify role is deleted
deleted = db.query(Role).filter(Role.id == role_id).first()
assert deleted is None
def test_delete_preset_role_raises_error(self, db, team_store):
"""Test deleting a preset role raises ValueError."""
# Ensure default roles exist
store_team_service.get_store_roles(db, team_store.id)
db.flush()
# Find the "staff" preset role
staff_role = (
db.query(Role)
.filter(Role.store_id == team_store.id, Role.name == "staff")
.first()
)
assert staff_role is not None
with pytest.raises(InvalidRoleException, match="preset role"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=staff_role.id,
)
def test_delete_role_not_found_raises_error(self, db, team_store):
"""Test deleting non-existent role raises ValueError."""
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=99999,
)
def test_delete_role_with_members_raises_error(self, db, team_store, test_user, other_user, auth_manager):
"""Test deleting a role that has assigned members raises ValueError."""
role = store_team_service.create_custom_role(
db=db,
store_id=team_store.id,
name="in_use_role",
permissions=["products.view"],
actor_user_id=test_user.id,
)
db.flush()
# Assign the role to a store user
store_user = StoreUser(
store_id=team_store.id,
user_id=other_user.id,
role_id=role.id,
is_active=True,
invitation_accepted_at=datetime.utcnow(),
)
db.add(store_user)
db.flush()
with pytest.raises(InvalidRoleException, match="team member"):
store_team_service.delete_role(
db=db,
store_id=team_store.id,
role_id=role.id,
)