- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
344 lines
14 KiB
Python
344 lines
14 KiB
Python
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
|
|
from infrasynth.security.models import Grant, Revoke, Role
|
|
from infrasynth.security.permissions import HybridPermission
|
|
from infrasynth.security.services import AuthorizationService
|
|
|
|
|
|
@pytest.fixture
|
|
def authz():
|
|
return AuthorizationService()
|
|
|
|
|
|
@pytest.fixture
|
|
def role(db, tenant):
|
|
return Role.objects.create(
|
|
tenant=tenant, name="Editor", slug="editor", permissions=["content.edit", "content.view"]
|
|
)
|
|
|
|
|
|
class TestPermissionResolutionChain:
|
|
def test_unauthenticated_denied(self, authz, db):
|
|
anon = type("Anon", (), {"is_authenticated": False})()
|
|
assert authz.has_permission(anon, "content.edit") is False
|
|
|
|
def test_no_permission_default_denied(self, authz, user):
|
|
assert authz.has_permission(user, "content.edit") is False
|
|
|
|
def test_superuser_always_allowed(self, authz, admin_user):
|
|
assert authz.has_permission(admin_user, "anything.at.all") is True
|
|
|
|
def test_role_permission_granted(self, authz, user, role):
|
|
role.users.add(user)
|
|
assert authz.has_permission(user, "content.edit") is True
|
|
|
|
def test_direct_grant_granted(self, authz, user):
|
|
Grant.objects.create(user=user, codename="billing.view")
|
|
assert authz.has_permission(user, "billing.view") is True
|
|
|
|
def test_expired_grant_denied(self, authz, user):
|
|
Grant.objects.create(
|
|
user=user,
|
|
codename="billing.view",
|
|
expires_at=timezone.now() - timedelta(days=1),
|
|
)
|
|
assert authz.has_permission(user, "billing.view") is False
|
|
|
|
def test_future_grant_allowed(self, authz, user):
|
|
Grant.objects.create(
|
|
user=user,
|
|
codename="billing.view",
|
|
expires_at=timezone.now() + timedelta(days=1),
|
|
)
|
|
assert authz.has_permission(user, "billing.view") is True
|
|
|
|
def test_revoke_beats_grant(self, authz, user):
|
|
Grant.objects.create(user=user, codename="content.edit")
|
|
Revoke.objects.create(user=user, codename="content.edit")
|
|
assert authz.has_permission(user, "content.edit") is False
|
|
|
|
def test_revoke_beats_role(self, authz, user, role):
|
|
role.users.add(user)
|
|
Revoke.objects.create(user=user, codename="content.edit")
|
|
assert authz.has_permission(user, "content.edit") is False
|
|
|
|
def test_revoke_only_affects_target_permission(self, authz, user, role):
|
|
role.users.add(user)
|
|
Revoke.objects.create(user=user, codename="content.edit")
|
|
assert authz.has_permission(user, "content.view") is True
|
|
|
|
def test_union_of_multiple_roles(self, authz, user):
|
|
Role.objects.create(slug="r1", name="R1", permissions=["perm.a"]).users.add(user)
|
|
Role.objects.create(slug="r2", name="R2", permissions=["perm.b"]).users.add(user)
|
|
assert authz.has_permission(user, "perm.a") is True
|
|
assert authz.has_permission(user, "perm.b") is True
|
|
|
|
def test_superuser_revoke_ignored(self, authz, admin_user):
|
|
Revoke.objects.create(user=admin_user, codename="anything")
|
|
assert authz.has_permission(admin_user, "anything") is True
|
|
|
|
|
|
class TestEffectivePermissions:
|
|
def test_superuser_returns_wildcard(self, authz, admin_user):
|
|
assert authz.get_effective_permissions(admin_user) == {"*"}
|
|
|
|
def test_unauthenticated_empty(self, authz, db):
|
|
anon = type("Anon", (), {"is_authenticated": False})()
|
|
assert authz.get_effective_permissions(anon) == set()
|
|
|
|
def test_merges_grant_and_role(self, authz, user, role):
|
|
role.permissions = ["role.perm"]
|
|
role.save()
|
|
role.users.add(user)
|
|
Grant.objects.create(user=user, codename="grant.perm")
|
|
perms = authz.get_effective_permissions(user)
|
|
assert perms == {"role.perm", "grant.perm"}
|
|
|
|
def test_revoked_permissions_removed(self, authz, user, role):
|
|
role.users.add(user)
|
|
Revoke.objects.create(user=user, codename="content.edit")
|
|
perms = authz.get_effective_permissions(user)
|
|
assert "content.edit" not in perms
|
|
assert "content.view" in perms
|
|
|
|
def test_empty_role_permissions(self, authz, user):
|
|
Role.objects.create(slug="empty", name="Empty", permissions=[]).users.add(user)
|
|
assert authz.get_effective_permissions(user) == set()
|
|
|
|
|
|
class TestAggregateChecks:
|
|
def test_has_all_permissions(self, authz, user, role):
|
|
role.permissions = ["perm.a", "perm.b"]
|
|
role.save()
|
|
role.users.add(user)
|
|
assert authz.has_all_permissions(user, ["perm.a", "perm.b"]) is True
|
|
assert authz.has_all_permissions(user, ["perm.a", "perm.c"]) is False
|
|
|
|
def test_has_any_permission(self, authz, user, role):
|
|
role.permissions = ["perm.a"]
|
|
role.save()
|
|
role.users.add(user)
|
|
assert authz.has_any_permission(user, ["perm.a", "perm.b"]) is True
|
|
assert authz.has_any_permission(user, ["perm.b", "perm.c"]) is False
|
|
|
|
|
|
class TestSystemUserPermissions:
|
|
@pytest.fixture
|
|
def system_user(self):
|
|
from infrasynth.security.auth.api_keys import SystemUser
|
|
|
|
return SystemUser(scopes=["read:users", "write:billing"])
|
|
|
|
def test_scope_permission_granted(self, authz, system_user):
|
|
assert authz.has_permission(system_user, "read:users") is True
|
|
assert authz.has_permission(system_user, "write:billing") is True
|
|
|
|
def test_unknown_scope_denied(self, authz, system_user):
|
|
assert authz.has_permission(system_user, "delete:users") is False
|
|
|
|
def test_effective_permissions_are_scopes(self, authz, system_user):
|
|
assert authz.get_effective_permissions(system_user) == {
|
|
"read:users",
|
|
"write:billing",
|
|
}
|
|
|
|
def test_empty_scopes_deny_all(self, authz, db):
|
|
from infrasynth.security.auth.api_keys import SystemUser
|
|
|
|
sys_user = SystemUser()
|
|
assert authz.has_permission(sys_user, "anything") is False
|
|
assert authz.get_effective_permissions(sys_user) == set()
|
|
|
|
|
|
class _PermissionView:
|
|
def __init__(self, required_permissions=None, require_all=False):
|
|
self.required_permissions = required_permissions
|
|
self.require_all = require_all
|
|
|
|
|
|
class TestHybridPermission:
|
|
def test_no_required_permissions_allows(self, user):
|
|
request = type("R", (), {"user": user})()
|
|
perm = HybridPermission()
|
|
assert perm.has_permission(request, _PermissionView(None)) is True
|
|
|
|
def test_any_permission_sufficient(self, user):
|
|
Grant.objects.create(user=user, codename="one.of.these")
|
|
request = type("R", (), {"user": user})()
|
|
perm = HybridPermission()
|
|
view = _PermissionView(["one.of.these", "other.perm"])
|
|
assert perm.has_permission(request, view) is True
|
|
|
|
def test_no_permission_denied_for_member(self, member_user):
|
|
request = type("R", (), {"user": member_user})()
|
|
perm = HybridPermission()
|
|
view = _PermissionView(["missing.perm"])
|
|
assert perm.has_permission(request, view) is False
|
|
|
|
def test_owner_is_allowed(self, user):
|
|
request = type("R", (), {"user": user})()
|
|
perm = HybridPermission()
|
|
assert perm.has_permission(request, _PermissionView(["missing.perm"])) is True
|
|
|
|
def test_unauthenticated_denied(self, db):
|
|
anon = type("R", (), {"user": type("U", (), {"is_authenticated": False})()})()
|
|
perm = HybridPermission()
|
|
assert perm.has_permission(anon, _PermissionView(None)) is False
|
|
|
|
|
|
class TestRequireAll:
|
|
def test_all_permissions_required(self, user):
|
|
Grant.objects.create(user=user, codename="perm.a")
|
|
Grant.objects.create(user=user, codename="perm.b")
|
|
request = type("R", (), {"user": user})()
|
|
view = _PermissionView(["perm.a", "perm.b"], require_all=True)
|
|
assert HybridPermission().has_permission(request, view) is True
|
|
|
|
def test_missing_any_denied(self, member_user):
|
|
Grant.objects.create(user=member_user, codename="perm.a")
|
|
request = type("R", (), {"user": member_user})()
|
|
view = _PermissionView(["perm.a", "perm.b"], require_all=True)
|
|
assert HybridPermission().has_permission(request, view) is False
|
|
|
|
def test_any_of_by_default(self, member_user):
|
|
Grant.objects.create(user=member_user, codename="perm.a")
|
|
request = type("R", (), {"user": member_user})()
|
|
view = _PermissionView(["perm.a", "perm.b"])
|
|
assert HybridPermission().has_permission(request, view) is True
|
|
|
|
|
|
class TestRoleViewSet:
|
|
def test_list_roles(self, authenticated_client, role):
|
|
resp = authenticated_client.get("/api/v1/auth/roles/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
|
|
def test_create_role(self, authenticated_client):
|
|
resp = authenticated_client.post(
|
|
"/api/v1/auth/roles/",
|
|
{"name": "Viewer", "slug": "viewer", "permissions": ["content.view"]},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_201_CREATED
|
|
assert Role.objects.filter(slug="viewer").exists()
|
|
|
|
def test_retrieve_role(self, authenticated_client, role):
|
|
resp = authenticated_client.get(f"/api/v1/auth/roles/{role.slug}/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["permissions"] == ["content.edit", "content.view"]
|
|
|
|
def test_update_role(self, authenticated_client, role):
|
|
resp = authenticated_client.put(
|
|
f"/api/v1/auth/roles/{role.slug}/",
|
|
{"name": "Editor2", "slug": "editor", "permissions": ["content.edit"]},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
role.refresh_from_db()
|
|
assert role.name == "Editor2"
|
|
|
|
def test_delete_role(self, authenticated_client, role):
|
|
resp = authenticated_client.delete(f"/api/v1/auth/roles/{role.slug}/")
|
|
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not Role.objects.filter(pk=role.pk).exists()
|
|
|
|
def test_system_role_cannot_be_deleted(self, authenticated_client, db):
|
|
system_role = Role.objects.create(slug="sys", name="Sys", permissions=[], is_system=True)
|
|
resp = authenticated_client.delete(f"/api/v1/auth/roles/{system_role.slug}/")
|
|
assert resp.status_code in (
|
|
status.HTTP_400_BAD_REQUEST,
|
|
status.HTTP_403_FORBIDDEN,
|
|
)
|
|
assert Role.objects.filter(pk=system_role.pk).exists()
|
|
|
|
def test_requires_auth(self, api_client):
|
|
resp = api_client.get("/api/v1/auth/roles/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestPermissionEnforcement:
|
|
def test_owner_allowed(self, authenticated_client):
|
|
assert authenticated_client.get("/api/v1/auth/roles/").status_code == status.HTTP_200_OK
|
|
|
|
def test_member_denied_without_permission(self, member_client):
|
|
assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_403_FORBIDDEN
|
|
|
|
def test_member_allowed_with_grant(self, member_client, member_user):
|
|
Grant.objects.create(user=member_user, codename="security.manage_roles")
|
|
assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_200_OK
|
|
|
|
def test_member_denied_for_unrelated_permission(self, member_client, member_user):
|
|
Grant.objects.create(user=member_user, codename="content.view")
|
|
assert member_client.get("/api/v1/auth/roles/").status_code == status.HTTP_403_FORBIDDEN
|
|
|
|
def test_user_permissions_endpoint(self, authenticated_client, user):
|
|
Grant.objects.create(user=user, codename="billing.view")
|
|
resp = authenticated_client.get(f"/api/v1/auth/users/{user.pk}/permissions/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert "billing.view" in resp.json()["permissions"]
|
|
|
|
def test_user_roles_set_endpoint(self, authenticated_client, user):
|
|
role = Role.objects.create(name="Viewer", slug="viewer", permissions=["content.view"])
|
|
resp = authenticated_client.put(
|
|
f"/api/v1/auth/users/{user.pk}/roles/",
|
|
{"roles": [role.slug]},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert role.slug in resp.json()["roles"]
|
|
assert role.users.filter(pk=user.pk).exists()
|
|
|
|
|
|
class TestGrantViewSet:
|
|
def test_create_grant(self, authenticated_client, user):
|
|
resp = authenticated_client.post(
|
|
"/api/v1/auth/grants/",
|
|
{"user": user.pk, "codename": "billing.view", "reason": "temporary"},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_201_CREATED
|
|
grant = Grant.objects.get(codename="billing.view")
|
|
assert grant.granted_by == user
|
|
|
|
def test_list_grants(self, authenticated_client, user):
|
|
Grant.objects.create(user=user, codename="a.perm")
|
|
resp = authenticated_client.get("/api/v1/auth/grants/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
|
|
def test_delete_grant(self, authenticated_client, user):
|
|
grant = Grant.objects.create(user=user, codename="a.perm")
|
|
resp = authenticated_client.delete(f"/api/v1/auth/grants/{grant.pk}/")
|
|
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not Grant.objects.filter(pk=grant.pk).exists()
|
|
|
|
def test_grant_requires_auth(self, api_client):
|
|
resp = api_client.get("/api/v1/auth/grants/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestRevokeViewSet:
|
|
def test_create_revoke(self, authenticated_client, user):
|
|
resp = authenticated_client.post(
|
|
"/api/v1/auth/revokes/",
|
|
{"user": user.pk, "codename": "billing.view", "reason": "audit"},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_201_CREATED
|
|
assert Revoke.objects.filter(codename="billing.view").exists()
|
|
|
|
def test_list_revokes(self, authenticated_client, user):
|
|
Revoke.objects.create(user=user, codename="a.perm")
|
|
resp = authenticated_client.get("/api/v1/auth/revokes/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
|
|
def test_delete_revoke(self, authenticated_client, user):
|
|
revoke = Revoke.objects.create(user=user, codename="a.perm")
|
|
resp = authenticated_client.delete(f"/api/v1/auth/revokes/{revoke.pk}/")
|
|
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not Revoke.objects.filter(pk=revoke.pk).exists()
|