171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
"""The access registry: every permission the product knows about, and its cache.
|
|
|
|
Two tables feed one list — the platform's own `accesses` and each module's
|
|
`module_accesses` — and the result is cached in Redis for an hour. That cache is
|
|
what the permission picker reads, so anything stale there is a permission an
|
|
administrator cannot grant, or one they can grant that no longer exists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.core.tenant_context import unscoped
|
|
from app.services.auth.access_service import AccessService
|
|
|
|
from .conftest import requires_db
|
|
|
|
pytestmark = requires_db
|
|
|
|
|
|
def _codes(rows):
|
|
return {row.access_code for row in rows}
|
|
|
|
|
|
def test_both_kinds_of_permission_appear(db, access_factory, module_factory,
|
|
module_access_factory):
|
|
"""A picker showing only one of the two is a picker that cannot express half
|
|
the product."""
|
|
module = module_factory()
|
|
access_factory("platform.thing")
|
|
module_access_factory(module, "module.thing")
|
|
|
|
with unscoped():
|
|
rows = AccessService.get_all_accesses(db)
|
|
|
|
assert {"platform.thing", "module.thing"} <= _codes(rows)
|
|
|
|
|
|
def test_each_permission_says_where_it_came_from(db, access_factory,
|
|
module_factory,
|
|
module_access_factory):
|
|
"""Two modules can both define "report.view". Without the source, the picker
|
|
shows two identical rows and no way to tell them apart."""
|
|
module = module_factory(module_name="Reporting")
|
|
access_factory("platform.thing")
|
|
module_access_factory(module, "module.thing")
|
|
|
|
with unscoped():
|
|
by_code = {r.access_code: r for r in AccessService.get_all_accesses(db)}
|
|
|
|
assert by_code["platform.thing"].module_name == "SaaS (Internal)"
|
|
assert by_code["module.thing"].module_name == "Reporting"
|
|
|
|
|
|
def test_a_category_filter_applies_to_both_kinds(db, access_factory,
|
|
module_factory,
|
|
module_access_factory):
|
|
module = module_factory()
|
|
access_factory("billing.read", category="Billing")
|
|
access_factory("report.read", category="Reports")
|
|
module_access_factory(module, "billing.export")
|
|
|
|
with unscoped():
|
|
rows = AccessService.get_all_accesses(db, category="Billing")
|
|
|
|
codes = _codes(rows)
|
|
assert "billing.read" in codes
|
|
assert "report.read" not in codes
|
|
|
|
|
|
def test_the_category_list_is_stable(db, access_factory):
|
|
"""It was built from a set and returned unordered, so the dropdown reordered
|
|
itself between requests for no reason the user could see."""
|
|
access_factory("a.read", category="Alpha")
|
|
access_factory("b.read", category="Bravo")
|
|
access_factory("c.read", category="Charlie")
|
|
|
|
with unscoped():
|
|
first = AccessService.get_access_categories(db)
|
|
second = AccessService.get_access_categories(db)
|
|
|
|
assert first == second
|
|
assert first == sorted(first)
|
|
|
|
|
|
def test_the_list_is_served_from_cache_on_the_second_read(db, access_factory,
|
|
fake_redis):
|
|
access_factory("cached.thing")
|
|
|
|
with unscoped():
|
|
AccessService.get_all_accesses(db)
|
|
assert fake_redis.get("saas:access:v2:all:full")
|
|
|
|
again = AccessService.get_all_accesses(db)
|
|
|
|
assert "cached.thing" in _codes(again)
|
|
|
|
|
|
def test_a_broken_cache_entry_does_not_break_the_list(db, access_factory,
|
|
fake_redis):
|
|
"""Falling back to the database is the only safe reaction. Raising would
|
|
make a corrupt cache key take the permission picker down."""
|
|
access_factory("resilient.thing")
|
|
fake_redis.set("saas:access:v2:all:full", "{not json")
|
|
|
|
with unscoped():
|
|
rows = AccessService.get_all_accesses(db)
|
|
|
|
assert "resilient.thing" in _codes(rows)
|
|
|
|
|
|
def test_a_sync_clears_the_category_caches_too(db, module_factory,
|
|
environment_factory, fake_redis,
|
|
monkeypatch):
|
|
"""It cleared exactly one key — the unfiltered one.
|
|
|
|
Every category-filtered listing kept serving the old permission set for up
|
|
to an hour after a module changed what it declares. The picker showed
|
|
permissions that no longer existed, and hid ones that had just appeared, and
|
|
the only clue was that the unfiltered view disagreed with the filtered one.
|
|
"""
|
|
from app.services.auth import module_permission_service
|
|
from app.services.auth.module_permission_service import ModulePermissionService
|
|
|
|
module = module_factory()
|
|
environment_factory(module, slug="prod", is_default=True)
|
|
|
|
class Response:
|
|
status_code = 200
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"permissions": [
|
|
{"permission_code": "fresh.thing", "name": "Fresh",
|
|
"category": "Reports"}
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
module_permission_service.requests, "post",
|
|
lambda *a, **kw: Response(),
|
|
)
|
|
|
|
with unscoped():
|
|
AccessService.get_all_accesses(db)
|
|
AccessService.get_all_accesses(db, category="Reports")
|
|
assert fake_redis.get("saas:access:v2:all:full")
|
|
assert fake_redis.get("saas:access:v2:all:Reports")
|
|
|
|
ModulePermissionService.sync_permissions(db, str(module.id))
|
|
|
|
assert fake_redis.get("saas:access:v2:all:full") is None
|
|
assert fake_redis.get("saas:access:v2:all:Reports") is None
|
|
|
|
assert "fresh.thing" in _codes(
|
|
AccessService.get_all_accesses(db, category="Reports")
|
|
)
|
|
|
|
|
|
def test_the_list_works_with_no_cache_at_all(db, access_factory):
|
|
"""Redis is optional. A missing cache must mean slower, not broken."""
|
|
access_factory("uncached.thing")
|
|
|
|
with unscoped():
|
|
rows = AccessService.get_all_accesses(db)
|
|
|
|
assert "uncached.thing" in _codes(rows)
|