mirror of
https://github.com/PegaProx/project-pegaprox.git
synced 2026-08-12 15:27:47 +08:00
Phase 3 of the test-suite roadmap. The RBAC tests exercise user_can_access_vm() directly; these fixtures drive REAL requests through the REAL Flask app + real blueprints so authz is asserted end-to-end — the layer the 2026-07-12 'BOLA' mis-framing proved the direct tests can't cover. Harness (tests/conftest.py): - session-scoped _integration_app: create_app() once, against an isolated temp DB so it never touches the developer's real encrypted DB (which has live clusters); save_sessions() silenced. - per-test isolation via the existing db fixture (routes call get_db() lazily, so re-pointing the db globals per test gives a clean DB while the app lives on). - auth via the REAL server-side session store (create_session -> active_sessions, X-Session-ID header) — the production login path, not a forged cookie. - managers faked + injected into pegaprox.globals.cluster_managers; the deny path (403) fires before the manager is touched, so deny-tests need no method stubs. - _ApiClient bakes in the session header + (for writes) the same-origin/XHR proof the CSRF gate requires. make_fake_manager() builds a per-test MagicMock manager. Smoke (tests/test_integration_smoke.py, 6 cases, green): anon 401; admin reads config 200 (manager reached); pool-reach user denied at the VM level 403 (manager proven never called); cross-tenant user denied at the cluster level 403; authed write without CSRF headers blocked; harness write path passes CSRF -> 404 routing. 83 passing locally (was 77).
285 lines
11 KiB
Python
285 lines
11 KiB
Python
# PegaProx authorization / tenant-isolation regression suite — shared harness.
|
|
#
|
|
# These tests exercise the RBAC layer (pegaprox/utils/rbac.py) directly against a
|
|
# throwaway encrypted DB — NO live PVE/ESXi cluster is needed, because
|
|
# user_can_access_vm / get_user_clusters / has_permission only read the DB
|
|
# (users, tenants, vm_acls, pool_permissions). The point is a permanent guard so
|
|
# the BOLA / tenant-isolation invariants that were historically fixed by hand
|
|
# (#490/#493/#495/#555 …) cannot silently regress on a refactor.
|
|
#
|
|
# Harness: gevent monkeypatch first, then point pegaprox.core.db at a per-test
|
|
# temp dir (CONFIG_DIR is a relative constant with no env override), reset the DB
|
|
# singleton, and seed via the real db.save_* methods.
|
|
|
|
import gevent.monkey
|
|
gevent.monkey.patch_all()
|
|
|
|
import os
|
|
import types
|
|
import tempfile
|
|
import shutil
|
|
|
|
import pytest
|
|
|
|
DEFAULT_TENANT = 'default'
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
"""A fresh, isolated, throwaway PegaProxDB pointed at a temp dir."""
|
|
tmp = tempfile.mkdtemp(prefix='pp_authz_test_')
|
|
import pegaprox.core.db as dbmod
|
|
|
|
# db.py binds CONFIG_DIR / DATABASE_FILE / KEY_FILE at import from constants;
|
|
# __init__ reads DATABASE_FILE and _init_encryption reads CONFIG_DIR at call
|
|
# time from these module globals — so redirecting them here is enough.
|
|
_orig = (dbmod.CONFIG_DIR, dbmod.DATABASE_FILE, dbmod.KEY_FILE)
|
|
dbmod.CONFIG_DIR = tmp
|
|
dbmod.DATABASE_FILE = os.path.join(tmp, 'pegaprox.db')
|
|
dbmod.KEY_FILE = os.path.join(tmp, '.pegaprox.key')
|
|
# get_db() caches in the module global `_db`; PegaProxDB is also a per-class
|
|
# singleton via `_instance`. BOTH must be cleared or get_db() hands back a
|
|
# connection to the previous test's (now-deleted) DB file.
|
|
dbmod._db = None
|
|
dbmod.PegaProxDB._instance = None
|
|
|
|
database = dbmod.get_db()
|
|
_reset_rbac_caches() # start each test with empty process-global caches
|
|
try:
|
|
yield database
|
|
finally:
|
|
try:
|
|
# PegaProxDB keeps its live handle on a threadlocal (self._local.conn);
|
|
# `_conn` is always None. Close the real connection so the temp DB file
|
|
# isn't held open when we rmtree it below.
|
|
_tlconn = getattr(getattr(database, '_local', None), 'conn', None)
|
|
if _tlconn is not None:
|
|
_tlconn.close()
|
|
if getattr(database, '_conn', None) is not None:
|
|
database._conn.close()
|
|
except Exception:
|
|
pass
|
|
dbmod._db = None
|
|
dbmod.PegaProxDB._instance = None
|
|
dbmod.CONFIG_DIR, dbmod.DATABASE_FILE, dbmod.KEY_FILE = _orig
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
_reset_rbac_caches()
|
|
|
|
|
|
def _reset_rbac_caches():
|
|
"""rbac.py caches tenants / custom-roles / VM-ACLs / pool-membership at module
|
|
scope (lazy-loaded and pinned). Without resetting them, the first test to touch
|
|
each cache pins that test's temp-DB view for the rest of the session and later
|
|
tests read stale authorization data. Clear them all so every test lazily
|
|
reloads from its own throwaway DB."""
|
|
try:
|
|
import pegaprox.utils.rbac as rbac
|
|
rbac.tenants_db = {}
|
|
rbac._custom_roles_cache = None
|
|
rbac._vm_acls_cache = None
|
|
with rbac._pool_cache_lock:
|
|
rbac._pool_membership_cache.clear()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _seed_user(db, username, role='user', tenant_id=DEFAULT_TENANT, enabled=True,
|
|
portal_only=False, permissions=None, denied=None, tenant_permissions=None):
|
|
db.save_user(username, {
|
|
'password_salt': 'x',
|
|
'password_hash': 'x',
|
|
'role': role,
|
|
'tenant_id': tenant_id,
|
|
'enabled': enabled,
|
|
'portal_only': portal_only,
|
|
'permissions': permissions or [],
|
|
'denied_permissions': denied or [],
|
|
'tenant_permissions': tenant_permissions or {},
|
|
})
|
|
# Return the same shape the app hands to rbac. build_authz_user() always sets
|
|
# user['username'] (auth.py:322); get_user() alone does not, so mirror it here
|
|
# or rbac's `username in allowed_users` ACL check would never match.
|
|
u = db.get_user(username)
|
|
u['username'] = username
|
|
return u
|
|
|
|
|
|
def _seed_tenant(db, tenant_id, clusters):
|
|
db.save_tenant(tenant_id, {'name': tenant_id, 'clusters': list(clusters)})
|
|
|
|
|
|
def _seed_vm_acl(db, cluster_id, vmid, users, inherit_role=True, permissions=None):
|
|
db.save_vm_acl(cluster_id, str(vmid), {
|
|
'users': list(users),
|
|
'inherit_role': inherit_role,
|
|
'permissions': permissions or [],
|
|
})
|
|
|
|
|
|
def _seed_pool_perm(db, cluster_id, pool_id, subject_id, permissions, subject_type='user'):
|
|
db.save_pool_permission(cluster_id, pool_id, subject_type, subject_id, list(permissions))
|
|
|
|
|
|
@pytest.fixture
|
|
def seed(db):
|
|
"""Convenience seeders bound to the per-test throwaway DB."""
|
|
ns = types.SimpleNamespace()
|
|
ns.db = db
|
|
ns.user = lambda username, **kw: _seed_user(db, username, **kw)
|
|
ns.tenant = lambda tid, clusters=(): _seed_tenant(db, tid, clusters)
|
|
ns.vm_acl = lambda cluster_id, vmid, users, **kw: _seed_vm_acl(db, cluster_id, vmid, users, **kw)
|
|
ns.pool = lambda cluster_id, pool_id, subject_id, perms, **kw: _seed_pool_perm(
|
|
db, cluster_id, pool_id, subject_id, perms, **kw)
|
|
return ns
|
|
|
|
|
|
# ===========================================================================
|
|
# Phase 3 — full-stack INTEGRATION harness.
|
|
#
|
|
# The RBAC tests above call user_can_access_vm() directly. That is fast but it
|
|
# skips the whole HTTP stack — the exact reason the 2026-07-12 "BOLA" framing was
|
|
# wrong (the guards behave differently once check_cluster_access + require_auth +
|
|
# the additive role-fallback all run in sequence). These fixtures drive REAL
|
|
# requests through the REAL Flask app + real blueprints, with the cluster managers
|
|
# faked out, so an authz decision is asserted end-to-end exactly as a browser (or
|
|
# an attacker) would experience it.
|
|
#
|
|
# Design:
|
|
# * ONE session-scoped app (create_app is heavy — background greenlets, etc.),
|
|
# built against an isolated temp DB so it NEVER touches the developer's real
|
|
# encrypted DB (which has live clusters). save_sessions() is silenced.
|
|
# * per-test DB isolation reuses the function-scoped `db` fixture: routes call
|
|
# get_db() lazily, so re-pointing the db module globals per test gives each
|
|
# test a clean DB while the app object lives on.
|
|
# * auth uses the REAL server-side session store (create_session -> active_sessions,
|
|
# addressed via the X-Session-ID header) — the same path production login uses.
|
|
# * managers are faked and injected into pegaprox.globals.cluster_managers (the
|
|
# dict every route imports by reference). The DENY path (403) fires before the
|
|
# manager is ever touched, so deny-tests need no method stubs; allow-tests stub
|
|
# exactly the manager method their route calls.
|
|
# ===========================================================================
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
def make_fake_manager(cluster_id='cluster_1', cluster_type='proxmox', **method_returns):
|
|
"""A stand-in cluster manager. Any attribute access works (MagicMock); the
|
|
methods a given route calls are stubbed via kwargs, e.g.
|
|
make_fake_manager(get_vm_config={'success': True, 'config': {...}})
|
|
Unstubbed methods return a MagicMock — fine for deny-tests (never called),
|
|
but an allow-test MUST stub the exact method its route invokes or the route
|
|
will try to jsonify a MagicMock and 500 (a loud, obvious failure)."""
|
|
m = MagicMock(name=f'FakeManager[{cluster_id}]')
|
|
m.cluster_id = cluster_id
|
|
m.cluster_type = cluster_type
|
|
m.name = cluster_id
|
|
m.online = True
|
|
for meth, ret in method_returns.items():
|
|
getattr(m, meth).return_value = ret
|
|
return m
|
|
|
|
|
|
@pytest.fixture(scope='session')
|
|
def _integration_app():
|
|
"""The real Flask app, created ONCE against an isolated temp DB."""
|
|
import pegaprox.core.db as dbmod
|
|
import pegaprox.utils.auth as authmod
|
|
|
|
tmp = tempfile.mkdtemp(prefix='pp_integ_app_')
|
|
dbmod.CONFIG_DIR = tmp
|
|
dbmod.DATABASE_FILE = os.path.join(tmp, 'pegaprox.db')
|
|
dbmod.KEY_FILE = os.path.join(tmp, '.pegaprox.key')
|
|
dbmod._db = None
|
|
dbmod.PegaProxDB._instance = None
|
|
|
|
# never persist test sessions to disk
|
|
authmod.save_sessions = lambda *a, **k: None
|
|
|
|
from pegaprox.app import create_app
|
|
app = create_app()
|
|
app.config['TESTING'] = True
|
|
|
|
try:
|
|
yield app
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
class _ApiClient:
|
|
"""Thin wrapper over the Flask test client that bakes in the session header
|
|
and — for state-changing verbs — the same-origin + XHR headers the CSRF gate
|
|
requires. base_url is pinned to http://localhost so request.host == 'localhost'
|
|
(same-origin matching)."""
|
|
_BASE = 'http://localhost'
|
|
|
|
def __init__(self, client, session_id=None):
|
|
self._c = client
|
|
self.session_id = session_id
|
|
|
|
def _headers(self, extra, write):
|
|
h = {}
|
|
if self.session_id:
|
|
h['X-Session-ID'] = self.session_id
|
|
if write:
|
|
h['X-Requested-With'] = 'XMLHttpRequest'
|
|
h['Origin'] = self._BASE
|
|
if extra:
|
|
h.update(extra)
|
|
return h
|
|
|
|
def _call(self, method, path, write, headers=None, **kw):
|
|
fn = getattr(self._c, method)
|
|
return fn(path, headers=self._headers(headers, write), base_url=self._BASE, **kw)
|
|
|
|
def get(self, path, **kw): return self._call('get', path, False, **kw)
|
|
def delete(self, path, **kw): return self._call('delete', path, True, **kw)
|
|
def post(self, path, **kw): return self._call('post', path, True, **kw)
|
|
def put(self, path, **kw): return self._call('put', path, True, **kw)
|
|
def patch(self, path, **kw): return self._call('patch', path, True, **kw)
|
|
|
|
|
|
@pytest.fixture
|
|
def api(_integration_app, db):
|
|
"""Full-stack test harness. `db` gives a fresh per-test DB; this clears the
|
|
process-global session + manager state so tests can't leak into each other."""
|
|
import pegaprox.utils.auth as authmod
|
|
import pegaprox.globals as ppglobals
|
|
|
|
with authmod.sessions_lock:
|
|
authmod.active_sessions.clear()
|
|
ppglobals.cluster_managers.clear()
|
|
|
|
client = _integration_app.test_client()
|
|
|
|
def as_user(user):
|
|
"""Mint a real server-side session for a seeded user dict and return a
|
|
client that authenticates as them."""
|
|
from pegaprox.utils.auth import create_session
|
|
with _integration_app.test_request_context('/', base_url=_ApiClient._BASE):
|
|
sid = create_session(user['username'], user['role'])
|
|
return _ApiClient(client, sid)
|
|
|
|
def set_manager(cluster_id, fake):
|
|
ppglobals.cluster_managers[cluster_id] = fake
|
|
return fake
|
|
|
|
ns = types.SimpleNamespace(
|
|
app=_integration_app,
|
|
as_user=as_user,
|
|
anon=lambda: _ApiClient(client, None),
|
|
set_manager=set_manager,
|
|
make_fake_manager=make_fake_manager,
|
|
)
|
|
try:
|
|
yield ns
|
|
finally:
|
|
with authmod.sessions_lock:
|
|
authmod.active_sessions.clear()
|
|
ppglobals.cluster_managers.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def make_fake_manager_fixture():
|
|
"""Expose the factory as a fixture too, for tests that prefer injection."""
|
|
return make_fake_manager
|