Multitenancy & Object Storage — Supported Configurations & Boundaries
Multitenancy (the [multitenancy] section of cuckoo.conf, powered by the
built-in lib.tenancy module) scopes every read surface so a user of one tenant
cannot see or act on another tenant’s tasks, samples, reports, artifacts,
statistics, search results, or live-VM (Guacamole) sessions, and allows
administrators to control which VMs (machines), network routes (routing),
and machine tags different Django ``Group``s can see and use.
Alongside [multitenancy], lib.tenancy provides pluggable cloud-agnostic
artifact object storage (the [object_storage] section of cuckoo.conf,
supporting s3 for AWS S3 / MinIO / Ceph RGW, gcs for Google Cloud
Storage, azure for Azure Blob Storage, and local for shared NFS mounts).
Both [multitenancy] and [object_storage] default to enabled = no and
can be enabled independently or together.
Configuration (conf/cuckoo.conf)
Both features are configured in conf/cuckoo.conf and default to enabled = no:
[multitenancy]
# [BETA] Multi-tenancy and per-group sandbox resource policy support.
enabled = no
# "shared" (default visibility = public) or "locked" (default visibility = tenant)
mode = shared
default_visibility =
# Superusers see and manage every tenant's tasks.
local_admins_manage_all_tenants = yes
# Anyone who can read a task may comment on it (default: manage rights needed).
readers_can_comment = no
[object_storage]
# [BETA] Central / remote object storage integration (s3, gcs, azure, local).
enabled = no
# Backend: s3 | gcs | azure | local
backend = s3
bucket =
prefix = results
region = us-east-1
endpoint_url =
access_key =
secret_key =
gcp_project =
gcp_credentials_path =
azure_connection_string =
azure_account_url =
azure_credential =
local_root =
Optional Dependencies:
[multitenancy]requires no extra packages (uses core Django, SQLAlchemy, and PyMongo).[object_storage]requires only the SDK for the backend you configure:backend = s3(AWS S3, MinIO, Ceph RGW):poetry install --extras s3backend = gcs(Google Cloud Storage):poetry install --extras gcpbackend = azure(Azure Blob Storage):poetry install --extras azurebackend = local(shared NFS / filesystem): no extra packages required.
Note
Enabling [web_auth] enabled = yes (conf/web.conf) and
token_auth_enabled = yes (conf/api.conf) with [multitenancy] enabled = no
authenticates callers onto a shared collaborative single-tenant sandbox:
lib.tenancy is not loaded (zero tenancy overhead), and authenticated users
can list, view, and manage tasks across the instance. To isolate tasks across
users or teams (tasks/view, tasks/list, tasks/delete, tasks/stats,
/analysis/remove/, and MongoDB/Elasticsearch search), set
[multitenancy] enabled = yes with default_visibility = private (strict
per-user isolation), mode = locked (per-group tenant isolation), or
mode = shared (public read defaults with owner/admin-only deletes).
Enabling on an existing (populated) install — run the backfill
Turning enabled = yes stamps tenant/visibility onto new analyses only.
Reports already in MongoDB have no info.tenant_id / info.user_id /
info.visibility stamp, so the scoped search / statistics / compare surfaces
treat them as fail-closed / invisible to every tenant until they are
stamped. Run the batched backfill helper from lib.tenancy.backfill
(backfill_from_task_acl_batched or backfill_legacy_uniform) once after
enabling the flag.
Upgrading from an install that ran the earlier inline tenancy code: those
reports already carry an info.tenant_id stamp, but from the old Tenant
table ids, which can collide with the Django Group ids used now and expose a
report to the wrong tenant. The backfills above skip already-stamped reports, so
restamp them from task_acl instead. Stop the web and processing services,
then run (MongoDB and/or Elasticsearch, whichever is enabled):
poetry run python utils/tenancy_reconcile.py --dry-run
poetry run python utils/tenancy_reconcile.py --adopt-public-from-reports
In addition to restamping MongoDB and Elasticsearch reports from task_acl,
utils/tenancy_reconcile.py automatically:
Carries over any
tasks.visibility = 'public'rows intotask_aclwithtenant_id = NULLiftasks.visibilityhas not been dropped yet (or adopts public reports without atask_aclrow when passed--adopt-public-from-reports; pass--demote-publicinstead to demote unrecorded public reports toprivate).Drops the legacy
users_userprofile.is_tenant_admincolumn left behind by the retiredusers.0004migration if present on the SQL database.
A report with no task_acl row is restamped private to its task’s
submitter (tasks.user_id), as the web UI already resolves it, or to no one,
so only admins can see it. The command is idempotent.
A report store holds one task-id space: the server’s whose tasks they are. Run
the command there (the main or central server); it refuses to run on a node that
analyses tasks for another server, whose reports carry that server’s task ids. If
the main/central server itself looks like one (for example a rescheduled copy of a
dist task), pass --force there.
Only a task’s own report is restamped (matched by info.id). A worker
that has already deleted its tasks can no longer tell that it is one: don’t run
the command on workers.
A worker stamps a dist report with the ACL the main server sent it when the task
was dispatched. After fetching the report (or --force-reported),
utils/dist.py re-applies the task’s current task_acl to it, so a
visibility change made while the task ran is kept. The Go fast-fetcher
(utils/go-fetcher) does not: with it, run the command below for the task. To do the same for one task by hand (for example after reprocessing
it on a worker, or from an object-storage broker once a bridged report lands):
poetry run python utils/tenancy_reconcile.py --task <id>
Django Group-Native Tenancy & Sandbox Resource Policies
In lib.tenancy, Django’s built-in django.contrib.auth.models.Group
(/admin/auth/group/) is the single administrative surface for both:
Tenant Isolation (Who sees which tasks & reports): When
is_tenant_boundary = True(the default onGroupSandboxPolicy) andtenantis left blank, the DjangoGroup’s own numeric primary key (group.id) is thetenant_idstamped ontoTaskAclrows and MongoDBinfo.tenant_iddocuments. Administrators do not need to populate a separateTenanttable unless they want multiple tenant-boundary groups to alias to a single externalTenantrow.Sandbox Resource Entitlements (Who sees & uses which VMs, Routes, and Tags):
GroupSandboxPolicy(attached inline on/admin/auth/group/) controls which VMs, network routes, and machine tags members of that group can see in the Web UI / APIv2 and request at task submission time:is_tenant_boundary(defaultTrue): whenTrue,group.idacts as a tenant ID forvisibility="tenant"report sharing. SetFalsefor pure Role / Capability add-on groups (e.g.role-direct-internet) that grant extra routes or VMs without creating a separate report silo.is_tenant_admin_group(defaultFalse): grants tenant-admin privileges (toggle visibility / deletetenant-scoped analyses) strictly for that group’stenant_id.priority(default0): higher priority wins when resolving a user’s primary submissiontenant_idanddefault_routeacross multiple groups.allowed_machines: list of allowed VM names or glob patterns (e.g.["win10_tier1_*", "win11_x64"]or["*"]).allowed_tags: list of allowed machine tags or glob patterns (e.g.["win10", "x64", "office*"]or["*"]).allowed_routes: list of allowed routing options (e.g.["none", "inetsim", "tor", "internet", "vpn:vpn_uk", "socks5:*", "exit:*"]or["*"]). A typed pattern such asvpn:*covers the routes configured as that type only (VPNs here), never the built-innone/drop/internet/inetsim/torroutes or the nexthoprandom/roundrobintokens.default_route: default route pre-selected in the submission UI and injected whenrouteis omitted onPOST /submit/orPOST /apiv2/tasks/create/*. Without one, an omitted route must be allowed asrouting.conf’s default route, else it becomesnone(or the submission is refused ifnoneis not allowed).default_visibility: optional per-group default task visibility ("private","tenant", or"public"). When blank, falls back tocuckoo.conf[multitenancy] default_visibility.parent_group,inherit_parent_resources(defaultTrue),inherit_parent_tasks(defaultTrue): optional parent/company Django Group from which this sub-group inherits baseline VMs/routes/tags and one-way shared task read access.forced_machine_tags: machine tags automatically appended when a user submits withmachine=""(“First available”) soScheduleronly picks VMs in that group’s pool. A user whoseallowed_machinesis not["*"]and who has no forced tag must name a machine. With several groups, the highest-priority policy that has forced tags supplies them.allow_all_vms,allow_interactive,max_timeout: per-group controls formachine="all", interactive Guacamole desktop sessions (submission and the console itself), and maximum analysis timeout.
Entitlements are checked twice: by the middleware on the submitted form or JSON
body (an early 403), and on the final Database.add arguments, which is
authoritative. In locked mode a user no active policy covers gets no
resources unless CAPE_TENANCY_UNASSIGNED_GROUP_POLICY = "allow".
Download & execute (the web host fetches the URL) and the sslproxy (except
over a VPN route), mitmdump and polarproxy options need the internet
route; netns= is refused. The deployment-wide statistics endpoints (/statistics/<days>/ and
/apiv2/tasks/statistics/<days>/) and yara_uploader span every tenant
(and every user’s private tasks) and are limited to local administrators whenever
multitenancy or web authentication is on, whereas /apiv2/tasks/stats/
(task_x_hours) scopes hourly task counts to the active viewer.
In distributed mode a task submitted without a route runs with the routing of
the worker that executes it: its default route, or a route its random_vpn /
random_socks5 picks. Give each group a default_route, which is submitted
explicitly.
On a distributed worker where lib.tenancy is active, the main server’s dist
API key must belong to a superuser, with local_admins_manage_all_tenants = yes
on the worker: main_task_id, parent_sample and task_acl attributes from
anyone else are dropped, and any other key sees only its own tasks in the
worker’s reports, task lists and cuckoo/status queue counts.
Likewise, utils/gcp_pubsub_service.py runs as an internal service without a
request viewer (and therefore counts as a trusted submitter) and copies
main_task_id, task_acl and arbitrary attributes from the Pub/Sub
message payload onto the submitted task. Because those fields determine a
report’s task numbering and ACL, IAM publish permissions on the Pub/Sub topic
must be restricted to trusted operators and services.
Task Visibility Levels: private vs. tenant (Group) vs. public
Every task in lib.tenancy has one of three visibility states recorded in the
task_acl side-table (TaskAcl.visibility) and stamped into the MongoDB
report (info.visibility):
User-Private (``visibility = “private”``):
Who can view/download/search it: The submitting user (
task.user_id == viewer.user_id), Group Admins of that task’s group (is_tenant_admin_group = Truefortask.tenant_id), and break-glass box superusers (is_local_admin).Hidden from regular teammates and other groups: Regular members of the submitter’s own Django Group, parent/child groups, and admins of other groups cannot view, toggle, or delete another user’s
privatetask.How to submit a private task:
Pass
visibility=privateonPOST /submit/orPOST /apiv2/tasks/create/file/(any authenticated user can always chooseprivateregardless of their group’s default).Or set
default_visibility = "private"on the group’sGroupSandboxPolicy(or incuckoo.conf[multitenancy]) so all submissions by that group start out private until the user or group admin shares them.Or toggle an existing task to
privateafter submission via the Web UI dropdown (cape_tenancy/visibility_control.html) orPOST /apiv2/tasks/visibility/<task_id>/withvisibility=private.
Group / Tenant Shared (``visibility = “tenant”``):
Who can view/download/search it: The submitting user, all members of the task’s Django Group (
tenant_id == group.id), any child sub-group withparent_grouppointing to that group andinherit_parent_tasks = True, and break-glass box superusers.Who can manage/delete it: The submitting user, Group Admins (
is_tenant_admin_group = True) of that specific group, and break-glass box superusers.
Instance-Public (``visibility = “public”``):
Who can view/download/search it: All users on the CAPEv2 instance.
Who can delete it or make it private again: Only the original submitting user (
user_id) or a break-glass box superuser. A Group Admin can widen atenantorprivatetask topublic, but cannot delete apublictask or downgrade someone else’spublictask totenant/private.
Visibility & Permission Matrix
Principal |
|
|
|
|---|---|---|---|
Submitting User |
Read / Toggle / Delete |
Read / Toggle / Delete |
Read / Toggle / Delete |
Group Admin |
Read / Toggle / Delete |
Read / Widen / Delete |
Read (No Delete/Narrow) |
Same-Group Member |
Denied (404) |
Read |
Read |
Child Sub-Group |
Denied (404) |
Read (if inherit=True) |
Read |
Parent Group Member |
Denied (404) |
Denied (404) |
Read |
Other Group / Admin |
Denied (404) |
Denied (404) |
Read |
Box Superuser |
Read / Toggle / Delete |
Read / Toggle / Delete |
Read / Toggle / Delete |
Administrator Guide: 4 Common Setup Patterns
Pattern 1: One Django Group = One Team / Tenant (Recommended Default)
Use this when each team, SOC tier, or customer organization is a Django Group:
Open Django Admin → Authentication and Authorization → Groups → Add group.
Name the group (e.g.
SOC-Team-Alpha).In the Sandbox Resource Policy (VMs, Routes, Tags) inline section on the same page:
Leave Is tenant boundary checked (
True) and Tenant blank —SOC-Team-Alpha’sgroup.idautomatically becomes itstenant_id.Set
allowed_machines(e.g.["win10_alpha_*"]),allowed_routes(e.g.["none", "inetsim", "vpn:vpn_alpha"]),default_route("inetsim"), andforced_machine_tags(["pool_alpha"]).
Assign users to
SOC-Team-Alpha(either manually in Django Admin or automatically via OIDC/SAMLidp_groups).Members of
SOC-Team-Alphawill only seewin10_alpha_*VMs andinetsim/vpn_alpharoutes, and any analysis they submit withvisibility="tenant"is shared exclusively withSOC-Team-Alpha.
Pattern 2: Multi-Group Users (Cross-Team Incident Responders / Leads)
Because ViewerContext tracks tenant_ids: tuple[int, ...] across all of a
user’s active tenant-boundary groups:
A user assigned to both
SOC-Team-Alpha(group.id=10) andSOC-Team-Beta(group.id=20) automatically:Reads
visibility="tenant"analyses from both teams (filtered viaTaskAcl.tenant_id IN (10, 20)in SQL,{"info.tenant_id": {"$in": [10, 20]}}in MongoDB, and{"terms": {"info.tenant_id": [10, 20]}}in Elasticsearch).Receives the union of both groups’
allowed_machines,allowed_routes, andallowed_tags.Stamps newly submitted tasks with the
tenant_idof whichever group has the higherpriorityvalue.Retains per-group admin isolation (
admin_tenant_ids): if the user is a group admin inSOC-Team-Alpha(is_tenant_admin_group=True) and a regular member inSOC-Team-Beta(is_tenant_admin_group=False), they can view/manage/deleteSOC-Team-Alphatasks (includingprivatetasks inSOC-Team-Alpha) without being able to delete or view privateSOC-Team-Betatasks.
Pattern 3: Capability / Role Add-On Groups Inside a Team (is_tenant_boundary = False)
Use this when analysts within the same team share one pool of reports, but a
subset of users needs extra VMs or network routes (e.g. direct internet or a
restricted VPN):
Keep the team group
SOC-Team-Alphawithis_tenant_boundary = Trueand restricted baseline routes (["none", "inetsim"]).Create a second Django Group
Role-Internet-Accesswith:is_tenant_boundary = False(unchecked)allowed_routes = ["internet", "tor"]
Add senior analysts to both
SOC-Team-AlphaandRole-Internet-Access.Because
is_tenant_boundary = FalseonRole-Internet-Access, it grants theinternetandtorroutes to those senior analysts without changing theirtenant_idor splitting their shared reports away fromSOC-Team-Alpha.
Pattern 4: Sub-Groups Inside a Main Company Group (parent_group One-Way Inheritance)
Use this when you have a main company group (e.g. Company-Main) and a
specialized sub-team (e.g. Team-Alpha / Threat Intel / Red Team) where:
You only want to assign
Team-Alphaanalysts to one group (Team-Alpha) instead of manually adding them to bothCompany-MainandTeam-Alpha.Team-Alphamembers should automatically inheritCompany-Main’s baseline VMs, routes, and tags and be able to viewCompany-Main’s shared analyses.General
Company-Mainmembers must not seeTeam-Alpha’s sensitive analyses or useTeam-Alpha’s specialized VMs/routes.
Steps in /admin/auth/group/:
Create
Company-Main(parent_group = None,allowed_machines = ["win10_company_*"],allowed_routes = ["none", "inetsim"]).Create
Team-Alphaand set:parent_group = Company-Maininherit_parent_resources = True(checked by default)inherit_parent_tasks = True(checked by default)allowed_machines = ["win11_alpha_*"],allowed_routes = ["vpn:vpn_alpha"]
Add sub-team members only to
Team-Alpha. They automatically inheritCompany-Main’s VMs/routes and read bothTeam-AlphaandCompany-Mainshared tasks, whileCompany-Mainusers cannot seeTeam-Alpha’s tasks or VMs.
Supported (isolation enforced end-to-end)
Report store: MongoDB. MT scoping of the aggregate/search/statistics/ compare surfaces reads the tenant stamp (
info.tenant_id/info.user_id/info.visibility) written into the mongo analysis document via the coreregister_report_hook/register_mongo_filterhooks. MongoDB is required for multitenancy.Single-node CAPE (one host running web + processing + analysis).
Single-tenant with ``lib.tenancy`` loaded and ``WEB_AUTHENTICATION = True`` (
[multitenancy] enabled = noalongside[object_storage] enabled = yesor explicitlib.tenancy.install(globals())): non-staff users’ submissions default toprivate, and non-staff users can view, manage, and delete their own private tasks while staff/superusers retain full local-admin visibility. Legacy ownerless tasks (user_id = Noneor0) resolve aspublicon direct read/sample checks, cannot be deleted or toggled by non-staff users, and remain hidden from un-ACL’d SQL list/count queries until stamped. Group sandbox resource policies (GroupSandboxPolicy) can be used wheneverlib.tenancyis active.Cloud / Object Storage Artifact Serving (
[object_storage]incuckoo.conf, implemented inlib.tenancy.storage): workers upload analysis artifacts and Guacamole session recordings to S3/MinIO (backend = s3), Google Cloud Storage (backend = gcs), Azure Blob Storage (backend = azure), or a shared NFS mount (backend = local), and the web UI streams or lazily stages artifacts on demand viaTenancyMiddleware.process_view. Multi-tenancy itself requires no extra packages; for object storage, install only the SDK for your chosen backend:poetry install --extras s3(or-E s3),poetry install --extras gcp(or-E gcp), orpoetry install --extras azure(or-E azure).Guacamole interactive sessions for task-backed analyses: gated by
can_toggle/can_manage(owner / tenant-admin / break-glass), not mere read visibility.On-demand processing and comments write into a task’s report, so they take the same manage rights. Set
readers_can_comment = yesto let anyone who can read a task comment on it. With multitenancy off, both keep stock behaviour.Duplicate checks (
unique=1/[uniq_submission]) only count tasks the submitter can see, so another tenant’s copy of a sample neither blocks a submission nor reveals that it exists.The audit framework (
/audit/) runs deployment-wide test sessions, so it is restricted to box administrators when multitenancy is on.
Architecture Overview
Core Logic Predicates (``lib/tenancy/policy.py``, ``lib/tenancy/resources.py``): Pure dataclasses (
ViewerContext,ResourceScope,ResourceEntitlements) and predicates (can_read,can_toggle,can_delete,can_set_visibility,scope_match,viewer_scope_match,viewer_scope_es_filter,merge_group_policies,validate_and_normalize_submission).Side-Table ACL & Monotonic CAS (``lib/tenancy/acl.py``): Stores per-task tenancy metadata in the dedicated
task_acltable without altering CAPEv2’s coretaskstable, and synchronizes visibility changes to MongoDB using a lock-free monotonicvisibility_seqcompare-and-swap (CAS) predicate.Middleware View & Route Gating (``lib/tenancy/middleware.py``, ``lib/tenancy/views.py``):
TenancyMiddlewareresolvesrequest.viewerandrequest.entitlements, binds per-requestContextVar``s, gates ``task_id/analysis_id/ sample hash /machines_view/ task submission routes inprocess_view, servesPOST /apiv2/tasks/visibility/<task_id>/, and dispatches object-storage artifact views.Cloud-Agnostic Object Storage (``lib/tenancy/storage/``): Pluggable
ArtifactStorebackends (S3Store,GCSStore,AzureBlobStore,LocalFSStore) configured via[object_storage](with backward-compatible[central_mode]andlib.tenancy.centralaliases).Core Hook Integration (``lib/tenancy/hooks.py`` -> ``lib.cuckoo.common.hooks``): Automatically registers
default_mongo_analysis_filter,default_sql_task_query_filter,default_es_query_filter,default_report_stamp_hook, and wrapslist_machines(),load_vms_tags(),load_vms_exits(), andsubmission/index.htmlwhen[multitenancy] enabled = yesor[object_storage] enabled = yes.