obsidian/wiki/concepts/multitenant-fail-open-authz.md
2026-04-29 21:51:42 +01:00

4.1 KiB

title aliases tags sources created updated
Multi-Tenant Fail-Open Authorization Bug
fail-open-authz
multitenant-authz-bug
get-accessible-projects-none
security
authorization
multitenant
fastapi
python
bug
daily/2026-04-29.md
2026-04-29 2026-04-29

Multi-Tenant Fail-Open Authorization Bug

In multi-tenant systems, a function that retrieves accessible resource IDs for a user must return an empty list [] (deny-all) when the user has no org assignments — never None. Returning None is typically interpreted downstream as "no filter applied" (fail-open), giving the user unrestricted access to all tenants' data. This is a critical security vulnerability pattern that is easy to introduce and hard to detect through normal testing.

Key Points

  • get_accessible_project_ids() returning None means "no restriction" → fail-open (every project visible)
  • get_accessible_project_ids() returning [] means "deny all" → fail-closed (correct behavior)
  • Always use [] as the default return when a user has no assignments, never None or omitting a return statement
  • Use 404 Not Found (not 403 Forbidden) for cross-tenant resource access — returning 403 confirms the resource exists, which is an information disclosure vulnerability
  • Add a mandatory guard at the access-check boundary: if project_ids is None: raise ValueError("BUG: authz returned None")

Details

The Vulnerable Pattern

# ❌ DANGEROUS — returns None when no assignments exist
async def get_accessible_project_ids(user_id: str, db) -> list[str] | None:
    assignments = await db.org_assignments.find({"user_id": user_id}).to_list(None)
    if not assignments:
        return None  # ← Bug: caller treats None as "no filter"
    return [a["project_id"] for a in assignments]

# ❌ Caller fails open
async def list_projects(user_id: str, db):
    project_ids = await get_accessible_project_ids(user_id, db)
    query = {}
    if project_ids is not None:   # ← None passes this check!
        query["_id"] = {"$in": project_ids}
    return await db.projects.find(query).to_list(None)  # Returns ALL projects

The Correct Pattern

# ✅ SAFE — always returns a list, empty list = deny all
async def get_accessible_project_ids(user_id: str, db) -> list[str]:
    assignments = await db.org_assignments.find({"user_id": user_id}).to_list(None)
    if not assignments:
        return []  # ← Explicit deny-all
    return [a["project_id"] for a in assignments]

# ✅ Caller fails closed
async def list_projects(user_id: str, db):
    project_ids = await get_accessible_project_ids(user_id, db)
    # Empty list → $in: [] → MongoDB returns zero documents ✓
    return await db.projects.find({"_id": {"$in": project_ids}}).to_list(None)

Cross-Tenant Access: 404 Not 403

# ❌ Leaks existence of the resource
async def get_project(project_id: str, user_id: str, db):
    project = await db.projects.find_one({"_id": project_id})
    if not project:
        raise HTTPException(404)
    if project["tenant_id"] != user_tenant(user_id):
        raise HTTPException(403, "Forbidden")  # ← Tells attacker the project exists!

# ✅ 404 for all cross-tenant access — no existence disclosure
async def get_project(project_id: str, user_id: str, db):
    project = await db.projects.find_one({
        "_id": project_id,
        "tenant_id": user_tenant(user_id)   # ← Filter by tenant in the query itself
    })
    if not project:
        raise HTTPException(404)  # Same response whether not found OR wrong tenant

Defensive Guard at Boundary

# Add a guard wherever authz results are consumed
project_ids = await get_accessible_project_ids(user_id, db)
assert project_ids is not None, "BUG: authz function returned None — fail-open risk"

Sources