PegaProx_project-pegaprox/tests/test_authz_api_token.py
mkellermann97 48a04661a2 test: add authorization / tenant-isolation regression suite
First automated coverage for the RBAC layer (pegaprox/utils/rbac.py) — the
highest-risk security surface in a multi-tenant cluster manager and the one
static scanners (Aikido/CodeAnt) structurally can't see. 17 in-process tests
(no live cluster needed) lock in the BOLA / tenant-isolation invariants that
were historically fixed only by hand review (#490/#493/#495/#555):

  - cross-tenant VM access denied; cluster list scoped to tenant
  - VM-ACL additive-not-restrictive model; #555 pool/ACL cluster-reach guard
  - API-token privilege floor (min(token, owner)); no admin-bypass for an
    admin-owned viewer token; mint-ceiling
  - pool.admin vs granular pool perms; pool grant doesn't leak to non-member
    VMs or across clusters

Harness: throwaway encrypted DB per test (temp dir + both DB singletons +
rbac process-caches reset → order-independent). pytest is dev-only
(requirements-dev.txt), not shipped in the appliance.

The suite already caught one real broken-access-control bug: the vm_acls table
has no inherit_role column, so an ACL saved with inherit_role=False (the UI's
'custom permissions' mode) is silently stored as FULL VM access. That test is
marked xfail(strict) with the root-cause + fix pointer until it's fixed.
2026-07-12 01:00:34 +02:00

62 lines
2.9 KiB
Python

# API-token scoping & privilege-floor invariants.
#
# The dangerous case: an ADMIN mints a low-privilege token. If the token carried
# the owner's stored (admin) role into object-level checks, it would silently be
# an admin token. build_authz_user() floors it to effective_role and the whole
# rbac chain (user_can_access_vm:754, has_permission:285, get_user_permissions:260)
# honours effective_role. These tests lock that in.
from pegaprox.utils.rbac import user_can_access_vm, has_permission
from pegaprox.utils.auth import build_authz_user, create_api_token
def test_admin_owned_viewer_token_is_floored_no_admin_bypass(seed):
seed.user('root', role='admin', tenant_id='default')
session = {'user': 'root', 'role': 'viewer', 'api_token': True}
tok_user = build_authz_user('root', session)
assert tok_user['role'] == 'admin' # stored account role unchanged
assert tok_user['effective_role'] == 'viewer' # floored for this token
# No admin short-circuit, no role-fallback escalation on VM ops:
assert user_can_access_vm(tok_user, 'c1', 100, 'vm.start') is False
# ...but the token still works at its real (viewer) level:
assert has_permission(tok_user, 'vm.view') is True
assert has_permission(tok_user, 'vm.start') is False
def test_token_effective_role_floors_to_owner_current_role(seed):
"""min(token_role, owner_role): a 'user' token whose owner was since demoted
to viewer must act as viewer, not user."""
seed.user('u', role='viewer', tenant_id='default')
session = {'user': 'u', 'role': 'user', 'api_token': True}
tok_user = build_authz_user('u', session)
assert tok_user['effective_role'] == 'viewer'
def test_session_auth_is_not_floored(seed):
"""Regression guard: the floor is token-only. Plain session auth leaves
effective_role unset so the real account role applies."""
seed.user('root', role='admin', tenant_id='default')
session = {'user': 'root', 'role': 'admin'} # no api_token flag
u = build_authz_user('root', session)
assert 'effective_role' not in u
assert user_can_access_vm(u, 'c1', 100, 'vm.start') is True
def test_cannot_mint_token_above_owner_role(seed):
"""create_api_token must refuse a token more privileged than its issuer."""
seed.user('viewer_bob', role='viewer', tenant_id='default')
res = create_api_token('viewer_bob', 'escalate', role='admin')
assert 'error' in res
seed.user('user_carol', role='user', tenant_id='default')
res2 = create_api_token('user_carol', 'escalate2', role='admin')
assert 'error' in res2 # non-admins can never mint an admin token
def test_can_mint_token_at_or_below_owner_role(seed):
seed.user('admin_alice', role='admin', tenant_id='default')
res = create_api_token('admin_alice', 'ci-readonly', role='viewer', expires_days=1)
assert 'error' not in res
assert res.get('token') # the plaintext token is returned exactly once