This document describes how the OpenStack Emulator implements multi-tenancy and resource isolation between projects (tenants).
OpenStack uses an identity model where domains contain users, groups, and projects as peers. Users gain access to project resources through role assignments:
Domain
├── Users
├── Groups
└── Projects (Tenants)
└── Resources (Servers, Volumes, Networks, etc.)
Role Assignments link Users/Groups to Projects/Domains:
User ──┬── Role ──► Project (scoped access)
└── Role ──► Domain (domain-wide access)
Group ─┬── Role ──► Project
└── Role ──► Domain
Key concepts:
- Users belong to Domains, not Projects
- Projects belong to Domains and contain resources
- Role Assignments grant users/groups specific roles on projects or domains
- A user can have different roles on different projects
The emulator implements tenant isolation to ensure resources belonging to one project are not accessible to other projects, mimicking real OpenStack behavior.
For more details on OpenStack identity concepts, see Keystone Identity Concepts.
These resources belong to a specific project and are isolated by default:
| Service | Resource | Isolation Field | Notes |
|---|---|---|---|
| Nova | Server | tenant_id |
Also tracks user_id |
| Nova | ServerGroup | project_id |
Also tracks user_id |
| Nova | Keypair | user_id |
User-scoped, not project-scoped |
| Cinder | Volume | project_id |
Also tracks user_id |
| Cinder | Snapshot | project_id |
Also tracks user_id |
| Neutron | Network | project_id |
Can be shared via shared flag |
| Neutron | Subnet | project_id |
|
| Neutron | Port | project_id |
|
| Neutron | Router | project_id |
|
| Neutron | FloatingIP | project_id |
|
| Neutron | SecurityGroup | project_id |
Default created per project |
| Neutron | SecurityGroupRule | project_id |
|
| Glance | Image | owner |
Visibility controls access |
| Octavia | LoadBalancer | project_id |
|
| Octavia | Listener | project_id |
|
| Octavia | Pool | project_id |
|
| Octavia | Member | project_id |
|
| Octavia | HealthMonitor | project_id |
|
| Octavia | L7Policy | project_id |
|
| Octavia | L7Rule | project_id |
These resources are shared across all projects:
| Service | Resource | Access Control |
|---|---|---|
| Nova | Flavor | is_public flag controls visibility |
| Keystone | Domain | Admin-only management |
| Keystone | Region | Admin-only management |
| Keystone | Service | Admin-only management |
| Keystone | Endpoint | Admin-only management |
| Keystone | Role | Admin-only management |
| Cinder | VolumeType | is_public flag controls visibility |
| Cinder | QosSpec | Admin-only management |
These resources are scoped to identity domains:
| Service | Resource | Scope Field |
|---|---|---|
| Keystone | Project | domain_id |
| Keystone | User | domain_id |
| Keystone | Group | domain_id |
| Keystone | Role (optional) | domain_id |
These resources belong to specific users:
| Service | Resource | Scope Field | Notes |
|---|---|---|---|
| Nova | Keypair | user_id |
Keyed as user_id:name |
| Keystone | Credential | user_id |
Optional project_id |
| Keystone | Token | user_id |
Session-based |
Most list operations support tenant filtering:
# List volumes for a specific project
def list_volumes(
self,
project_id: str | None = None,
all_tenants: bool = False,
) -> list[Volume]:
volumes = list(self._volumes.values())
if project_id and not all_tenants:
volumes = [v for v in volumes if v.project_id == project_id]
return volumesGet operations optionally verify ownership:
# Get volume with ownership check
def get_volume(self, volume_id: str, project_id: str | None = None) -> Volume | None:
volume = self._volumes.get(volume_id)
if volume is None:
return None
if project_id is not None and volume.project_id != project_id:
return None # Not owned by this project
return volumeUpdate and delete operations verify ownership:
# Delete only if owned
def delete_volume(self, volume_id: str, project_id: str | None = None) -> bool:
volume = self._volumes.get(volume_id)
if not volume:
return False
if project_id is not None and volume.project_id != project_id:
return False # Cannot delete other project's resources
del self._volumes[volume_id]
return TrueEach project gets a default security group created automatically:
def _ensure_default_security_group(self, project_id: str) -> SecurityGroup:
"""Ensure default security group exists for project."""
existing = self.get_security_group_by_name("default", project_id)
if existing:
return existing
# Create with default egress rules
sg = SecurityGroup(
name="default",
project_id=project_id,
description="Default security group",
)
# Add default egress rules...
return sgDeleting a security group follows Neutron's delete_security_group: a group
still bound to a port is refused with 409 SecurityGroupInUse for every
caller, and the default group is refused with
409 SecurityGroupCannotRemoveDefault only to a non-admin caller. An admin
token may delete a project's default group, as project cleanup does.
Networks can be shared between projects via:
sharedflag: Makes network visible to all projectsexternalflag: Makes network available as external gateway- RBAC Policies: Fine-grained sharing control
@dataclass
class Network:
project_id: str = ""
shared: bool = False # Visible to all projects
external: bool = False # router:external in API
@dataclass
class RbacPolicy:
object_type: str = "network"
object_id: str = ""
target_project: str = "" # '*' for all, or specific project_id
action: str = "access_as_shared"An RBAC share does not make the target project an owner, so the emulator reproduces two rules real Neutron applies to shared networks (both verified against RHOS 17):
- Pinning an IP requires admin or network ownership. A tenant may create a
port on a network shared to it and let Neutron allocate the address, but
passing
fixed_ips[].ip_addresson a network it does not own is answered with403(create_port:fixed_ips:ip_addressis admin-or-network-owner). The same request succeeds on the tenant's own network, or when an admin makes it on the tenant's behalf. - A share in use cannot be revoked. Deleting an
access_as_sharedpolicy while the target project still holds ports on the network is answered with409until those ports are gone.
An access_as_external share makes the network usable both as a router
gateway and as a floating IP network. Those two are the same question in
Neutron, so both go through Database.is_network_external_for() rather than
reading the external flag directly.
Two ports the emulator creates on an external network carry an empty
project_id, mirroring Neutron:
| Port | device_owner |
Created by |
|---|---|---|
| Router gateway | network:router_gateway |
setting external_gateway_info |
| Floating IP | network:floatingip |
creating a floating IP |
Neutron's wording is explicit — "Port has no 'project-id', as it is hidden from
user" and "This external port is never exposed to the project. it is used
purely for internal system and admin use". The practical consequence, given the
filters above, is that neither port appears in the owning tenant's /v2.0/ports
listing and neither is retrievable by id with a tenant token; an admin token
(which applies no project filter) sees both. Tests that need to assert on them
should go through db.list_ports(device_owner=...) rather than the tenant API.
Glance images have a visibility model:
| Visibility | Description |
|---|---|
public |
Visible to all projects |
private |
Only visible to owner |
shared |
Visible to specific projects via members |
community |
Visible to all, managed by community |
@dataclass
class GlanceImage:
owner: str = "" # project_id
visibility: ImageVisibility = ImageVisibility.PRIVATEImage members allow sharing with specific projects:
@dataclass
class ImageMember:
image_id: str = ""
member_id: str = "" # project_id to share with
status: str = "pending" # pending, accepted, rejectedAttaching a port to a server (POST /v2.1/servers/{id}/os-interface) resolves
the port in the caller's project scope, mirroring how real Nova queries
Neutron with the user's context:
- A tenant token can only attach ports owned by its own project; a port owned
by another project (e.g. created by an admin without an explicit
tenant_id) yields404 Port id ... could not be found.— identical to a nonexistent port. - An admin token may attach any port.
- A port with
device_idalready set yields409 Port ... is still in use.(enforced inside the database layer, under its lock). - Attach by
net_idcreates the port on behalf of the server's project and binds it (device_id= server,device_owner=compute:nova). - A
fixed_ipis validated against the network's subnet CIDRs: an IP outside every subnet yields400 Fixed IP ... is not a valid ip address for network ..., an IP held by another port on the network yields409 Fixed IP ... is already in use., and the interface'ssubnet_idis resolved from the containing subnet. Neutron'sPOST /v2.0/portsapplies the same validation to explicitfixed_ips(400/409with Neutron-style messages). - Detach mirrors Nova's
deallocate_port_for_instance: a port created by the attach (net_idpath) is deleted, a pre-existing port is only unbound. Deleting a server releases its interfaces the same way.
The all_tenants parameter allows admin users to bypass tenant filtering:
# Admin listing all volumes across tenants
volumes = db.list_volumes(all_tenants=True)
# Regular user listing only their volumes
volumes = db.list_volumes(project_id="user-project-id")The Neutron API resolves the effective project per request from the caller's token, mirroring real OpenStack (and how Waldur drives the emulator — tenant operations use a token scoped to the tenant's project, while infrastructure operations use the cloud admin session):
- Project is taken from the token's scope. Keystone honors
scope.project.id(not only the project name), so a session scoped to a project by id is correctly attributed to that project. Resources a tenant creates are owned by the tenant — not the admin project. - On-behalf-of creation.
createendpoints honor a bodyproject_id/tenant_id(_resolve_project_id), so an admin can create a resource owned by another project (e.g. Waldur provisions a tenant's default router and instance ports from the admin session). - Cloud-admin = cross-project. A token scoped to the
adminproject may read/modify any project's resource by id (_lookup_project_idreturns no restriction), and its list results span all projects. This is required for admin operations on tenant-owned resources (external gateway, port security, port status, enumerating a tenant's networks/subnets/security groups). Tenant-scoped tokens remain restricted to their own project. Cinder's by-id volume and snapshot endpoints (show/update/delete, metadata, andvolumes/{id}/action) follow the same rule (_context_projectinemulator/api/cinder.py): the restriction comes from the token, never from the URL's project segment, so an admin acts on a tenant's volume through its own project's URL.os-force_deleteis admin-only (403otherwise, as Cinder's policy says) and refuses an attached volume with400. - Explicit list filters. List endpoints honor an explicit
tenant_id/project_id(and Neutron filters such asfixed_ips=subnet_id=…) so an admin session can scope a query to one tenant. As in Neutron (neutron_lib.db.model_query:apply_filtersvsquery_with_hooks), the owner filter and visibility are separate: visibility comes from the token (an admin sees everything; a project-scoped token sees its own networks plus those shared to it —shared/externalflags and RBAC to the project or*— and the subnets of those networks), andtenant_id/project_idnarrows that set by owner. An admin listingGET /v2.0/networks?tenant_id=Xtherefore gets only the networks X owns, never the ones merely shared to X;router:externalis then the network's own flag, not the per-projectaccess_as_externalview. The same applies to subnets, and to ports, which are never RBAC-shared. - RBAC ownership. An RBAC policy is owned by the shared object's project (e.g. the network's tenant), not the admin project that created it.
A token counts as "admin" when it is scoped to the admin project or when
its user genuinely holds the admin role on the scoped project. Only real role
assignments confer the latter: scoping a token to a project the user was never
granted a role on fails with 401 rather than minting a usable token, so an
unknown username can no longer inherit the seeded admin's privileges.
Each project has independent quotas:
| Service | Quota Model | Resources Tracked |
|---|---|---|
| Nova | NovaQuota |
instances, cores, ram, keypairs, server_groups |
| Neutron | NeutronQuota |
networks, subnets, ports, routers, floatingips, security_groups |
| Cinder | CinderQuota |
volumes, snapshots, gigabytes, backups |
@dataclass
class NovaQuota:
project_id: str = ""
instances: int = 10
cores: int = 20
ram: int = 51200 # MB
# ...When writing tests, ensure proper isolation by resetting the database:
import pytest
from emulator.core.database import db
@pytest.fixture(autouse=True)
def reset_database():
"""Reset database before each test."""
db.reset()
yieldFor multi-tenant tests, create separate projects and verify isolation:
def test_volume_isolation():
# Create volumes in different projects
vol1 = db.create_volume("vol1", 10, project_id="project-a", user_id="user-a")
vol2 = db.create_volume("vol2", 10, project_id="project-b", user_id="user-b")
# Verify isolation
assert db.get_volume(vol1.id, project_id="project-a") is not None
assert (
db.get_volume(vol1.id, project_id="project-b") is None
) # Can't see other project's volume
# Verify list isolation
project_a_volumes = db.list_volumes(project_id="project-a")
assert len(project_a_volumes) == 1
assert project_a_volumes[0].id == vol1.id- No general policy engine: The emulator does not evaluate OpenStack's
policy.yamlrules; individual rules are reproduced by hand where a client depends on them (e.g. Neutron'screate_port:fixed_ips:ip_address) - Coarse admin detection: Cross-project access follows the
adminproject scope or anadminrole assignment (see Neutron API-layer project scoping); outside Neutron,all_tenantsremains trust-based - No Ownership Transfer: Resources cannot be transferred between projects
- No Hierarchical Projects: Project hierarchy (parent_id) is stored but not enforced
- Data Models - Detailed model definitions
- Architecture Overview - System architecture