Roles are stored in the new resource:action format and any legacy keys are converted once at startup by the RBAC v2 DB migration (migrate_rbac_v2), so the permission engine no longer needs to resolve legacy codes on every check. - get_user_permissions: store role permission codes as-is (no dual add). - has_permission: match new-format codes only; drop resolve_permission and the dotted-code fallbacks. - Remove now-unused resolve_permission import in middleware. - LEGACY_PERMISSION_MAP / convert_* are retained as one-shot DB-migration utilities (upgrade path), not a runtime bridge. - Tests: adapt fixtures to new-format role permissions; assert legacy keys are no longer honored at request time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
6.1 KiB
Python
127 lines
6.1 KiB
Python
"""Smoke tests for the RBAC permission engine."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rbac():
|
|
return importlib.import_module("app.rbac")
|
|
|
|
|
|
class TestRegistry:
|
|
@pytest.mark.smoke
|
|
def test_well_known_permissions_registered(self, rbac):
|
|
for code in ("vm:view", "vm:create", "vm:delete", "user:create", "role:manage"):
|
|
assert rbac.PERMISSIONS.get(code) is not None, f"missing {code}"
|
|
|
|
def test_legacy_resolution(self, rbac):
|
|
assert rbac.resolve_permission("vms.create") == "vm:create"
|
|
assert rbac.resolve_permission("vm:create") == "vm:create"
|
|
# Unknown codes are returned untouched.
|
|
assert rbac.resolve_permission("totally:unknown") == "totally:unknown"
|
|
|
|
def test_validate_partitions_codes(self, rbac):
|
|
# The registry's internal legacy map uses ``resource.action`` form (e.g.
|
|
# ``vm.create``). The richer ``LEGACY_PERMISSION_MAP`` (e.g. ``vms.create``)
|
|
# is consumed by ``resolve_permission`` / the engine, not by ``validate``.
|
|
valid, invalid = rbac.PERMISSIONS.validate(["vm:view", "vm.create", "bogus:perm"])
|
|
assert "vm:view" in valid
|
|
assert "vm:create" in valid # registry legacy resolved
|
|
assert invalid == ["bogus:perm"]
|
|
|
|
|
|
class TestEngineHasPermission:
|
|
@pytest.mark.smoke
|
|
def test_admin_bypass(self, rbac, fake_user_factory):
|
|
admin = fake_user_factory(is_admin=True)
|
|
assert rbac.PermissionEngine.has_permission(admin, "vm:delete") is True
|
|
assert rbac.PermissionEngine.has_permission(admin, "anything:at:all") is True
|
|
|
|
def test_direct_grant(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:view": True})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is False
|
|
|
|
def test_legacy_keys_not_resolved_at_runtime(self, rbac, fake_user_factory):
|
|
# Roles are migrated to the new ``resource:action`` format once at
|
|
# startup (RBAC v2 DB migration). The engine no longer resolves legacy
|
|
# dot-format keys at request time, so a role still holding a legacy key
|
|
# grants nothing until it has been migrated.
|
|
user = fake_user_factory(permissions={"vms.create": True})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:create") is False
|
|
# The migration utility is what bridges the formats, not the engine.
|
|
assert rbac.resolve_permission("vms.create") == "vm:create"
|
|
|
|
def test_wildcard_grants_all_actions_on_resource(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:*": True})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is True
|
|
# Other resources untouched.
|
|
assert rbac.PermissionEngine.has_permission(user, "user:delete") is False
|
|
|
|
def test_manage_implies_common_actions(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:manage": True})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:start") is True
|
|
# delete is NOT in the implied set.
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is False
|
|
|
|
def test_user_without_role_has_no_permissions(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(role=None)
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is False
|
|
|
|
def test_disabled_permission_not_granted(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:view": False})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is False
|
|
|
|
|
|
class TestEngineCheckPermission:
|
|
def test_check_permission_raises_403(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={})
|
|
with pytest.raises(HTTPException) as exc:
|
|
rbac.PermissionEngine.check_permission(user, "vm:delete")
|
|
assert exc.value.status_code == 403
|
|
assert "vm:delete" in exc.value.detail
|
|
|
|
def test_check_permission_passes_for_admin(self, rbac, fake_user_factory):
|
|
admin = fake_user_factory(is_admin=True)
|
|
rbac.PermissionEngine.check_permission(admin, "vm:delete") # no raise
|
|
|
|
|
|
class TestAggregateHelpers:
|
|
def test_has_any_permission(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:view": True})
|
|
assert rbac.PermissionEngine.has_any_permission(user, ["vm:delete", "vm:view"]) is True
|
|
assert rbac.PermissionEngine.has_any_permission(user, ["vm:delete", "vm:create"]) is False
|
|
|
|
def test_has_all_permissions(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:view": True, "vm:create": True})
|
|
assert rbac.PermissionEngine.has_all_permissions(user, ["vm:view", "vm:create"]) is True
|
|
assert rbac.PermissionEngine.has_all_permissions(user, ["vm:view", "vm:delete"]) is False
|
|
|
|
def test_filter_permissions(self, rbac, fake_user_factory):
|
|
user = fake_user_factory(permissions={"vm:view": True})
|
|
kept = rbac.PermissionEngine.filter_permissions(user, ["vm:view", "vm:delete"])
|
|
assert kept == ["vm:view"]
|
|
|
|
|
|
class TestUserModelIntegration:
|
|
"""``User.has_permission`` should defer to PermissionEngine."""
|
|
|
|
def test_user_has_permission_delegates(self, rbac, fake_user_factory):
|
|
# Use the SimpleNamespace user — exercises the same call path as the
|
|
# real model thanks to duck typing in PermissionEngine.
|
|
user = fake_user_factory(permissions={"vm:view": True})
|
|
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
|
|
|
|
def test_get_user_permissions_admin_returns_full_set(self, rbac, fake_user_factory):
|
|
admin = fake_user_factory(is_admin=True)
|
|
perms = rbac.PermissionEngine.get_user_permissions(admin)
|
|
# Admin shortcut returns every registered permission code.
|
|
assert {"vm:view", "vm:delete", "user:create"}.issubset(perms)
|