Python's Vacuous Truth Trap: The Permission Check That Approves Everything

2026-07-08

This authorization layer gates every action in the app. A user must possess every permission listed for the action they're attempting. Simple, defensive, and — as of the last deploy — catastrophically wrong.

REQUIRED_PERMISSIONS = {
    "read_docs":   ["docs.read"],
    "edit_docs":   ["docs.read", "docs.write"],
    "admin_panel": ["admin"],
}

def user_can_perform(user, action):
    required = REQUIRED_PERMISSIONS.get(action, [])
    return all(user.has(perm) for perm in required)

def handle_request(user, action):
    if not user_can_perform(user, action):
        return "403 Forbidden"
    return execute(action)

# A new endpoint ships:
#   handle_request(guest_user, "delete_all_users")
# Nobody remembered to add "delete_all_users" to REQUIRED_PERMISSIONS.
# The guest gets a 200.

The Bug

Look at what happens when action isn't in the dict:

This is vacuous truth. Mathematically, "every element of the empty set satisfies P" is true, because there's no counterexample. Python faithfully implements this: all([]) is True, and dually, any([]) is False. The generator yields nothing, so all never sees a falsy value, so it returns its identity element for AND — which is True.

The bug lives at the intersection of two innocuous choices: dict.get(..., []) to avoid KeyError, and all() to express "must have every permission." Individually, both are idiomatic. Composed, they turn "unknown action" into "no restrictions."

The same shape appears everywhere:

The Fix

Treat "no rule registered" as a distinct state — not as "no rule needed." Fail closed:

def user_can_perform(user, action):
    if action not in REQUIRED_PERMISSIONS:
        raise UnknownAction(action)    # or: return False
    required = REQUIRED_PERMISSIONS[action]
    if not required:
        return False                   # explicit: empty = deny
    return all(user.has(perm) for perm in required)

If you genuinely want "empty means allowed" (say, for public endpoints), spell it out with a sentinel — PUBLIC = None and an is None check — so the intent lives in the code, not in a coincidence of set theory.

The deeper lesson: all()/any() on a possibly-empty iterable is a decision, not a default. Any predicate over "all X" implicitly answers "and what if there are no X?" — and Python answers "yes" whether you meant it or not. Every authorization check, every "all validations passed," every "no errors present" gate should think about the empty case explicitly, because your test suite almost certainly won't. The lookup that returns [] is the one that ships to production.

Key Takeaway: all([]) is True and any([]) is False — so any predicate you evaluate over a "missing" collection silently answers the vacuous case, and in security-adjacent code that answer is almost always the wrong one.

All newsletters