Private Object Stores
Two things in Galaxy are called a “private object store”, and they are not the same mechanism. This note covers both, plus the third concept they are routinely confused with — the permission system’s notion of a private dataset.
Verification anchor: Galaxy origin/dev at SHA e621a577509710e76b54aca458e44fbfaa136e00.
Every path:line below was read at this SHA.
Scope: (A) the private flag on object store backends, and (B) user-defined / BYOS object
stores (user_objects://…). Out of scope: general object store internals (dispatch, store_by,
device maps), individual backend implementations (S3/Azure/iRODS/Rucio/Pithos/Onedata), caching,
and sharding — touched only where privacy changes their behaviour.
Organising question: what does it mean for a dataset to live in storage that cannot be shared, and how does Galaxy actually enforce that? (A) and (B) are two different answers — and (B)‘s answer turns out to be “it doesn’t”.
Table of Contents
- #Three Things Called Private
- #Layer A — The private Backend Flag
- #Layer B — User-Defined BYOS Object Stores
- #Where A and B Meet — and Where They Don’t
- #Config Surface
- #Tests
- #Extension Points
- #Known Issues and Sharp Edges
Three Things Called Private
| Concept | Lives in | Predicate | Gates |
|---|---|---|---|
| Storage privacy | galaxy.objectstore | ObjectStore.is_private(obj) / ConcreteObjectStore.private | Whether the backend may hold data anyone but the owner can read |
| Permission privacy | galaxy.model.security | dataset_is_private_to_user(trans, dataset), dataset_is_private_to_a_user(dataset) | Whether this dataset row currently has exactly one PRIVATE access role |
| User-defined storage | galaxy.managers.object_store_instances | is_user_object_store(object_store_id) | Whether the store is a per-user BYOS instance |
Storage privacy is a static property of configuration. Permission privacy is a dynamic
property of a row’s DatasetPermissions. They are orthogonal — a dataset can be permission-private
in a shareable store (the common case under
new_user_dataset_access_role_default_private), and a store can be storage-private while
permissions default to public (a configuration that errors out at job time, see
#Config Surface).
They compose at exactly two places.
1. Dataset.shareable — lib/galaxy/model/__init__.py:4864-4871
@property
def shareable(self) -> bool:
"""Return True if placed into an objectstore not labeled as ``private``."""
if self.external_filename:
return True
else:
object_store = self._assert_object_store_set()
return not object_store.is_private(self)
ensure_shareable() (:4873-4875) raises MessageException(CANNOT_SHARE_PRIVATE_DATASET_MESSAGE);
the constant is at :336 — “Attempting to share a non-shareable dataset.”
2. Job.requires_shareable_storage(security_agent) — lib/galaxy/model/__init__.py:2355-2366.
This is the only place the permission system feeds the storage system: given the permissions
already computed for a job’s outputs, does this job need a store that permits sharing? The in-code
comment at :2356-2358 notes it could be hoisted into galaxy.tools.actions at job-creation time
when permissions are already known — an acknowledged inefficiency, not a bug.
Note the near-namesakes dataset_is_private_to_user (lib/galaxy/model/security.py:1142-1156) and
dataset_is_private_to_a_user (:1158-1170) differ only in whether they compare against a
specific user’s private role or accept any Role.types.PRIVATE role. Neither has anything to do
with ObjectStore.is_private.
Layer A — The private Backend Flag
Definition and parsing
In lib/galaxy/objectstore/__init__.py:
DEFAULT_PRIVATE = False(:72)ObjectStore.is_private(obj)— abstract (:373-374);BaseObjectStore.is_privatedelegates viaself._invoke("is_private", obj)(:730-731)BaseObjectStore.parse_private_from_config_xml(:736-741) — classmethod, reads theprivateXML attribute throughasboolConcreteObjectStore.__init__(:799) —self.private = config_dict.get("private", DEFAULT_PRIVATE), commented “Annotate this as true to prevent sharing of data.”;_is_privatereturns it (:869-870)- Serialisation into
ConcreteObjectStoreModel.privateviato_dict()(:815) /to_model()(:832) - XML ingress for disk at
DiskObjectStore.parse_xml(:958); YAML carries theprivate:key through
Badges: _get_concrete_store_badges (:855) passes private to serialize_badges
(objectstore/badges.py:92-94), which appends {"type": "restricted", "source": "galaxy"}
(badges.py:124-130). restricted is Galaxy-generated, not admin-settable.
Nested stores
DistributedObjectStore — privacy is per-backend. object_store_ids(private=None) (:1694-1700)
filters on backend.private. NestedObjectStore._is_private (:1340-1341) resolves the object’s
object_store_id to a concrete backend and asks that store, with a non-exception default of
False — so an unresolvable object reads as shareable.
HierarchicalObjectStore — privacy is store-wide, enforced by assertion at :1745-1755: a
contained backend may restate the parent’s value but not contradict it. parse_xml (:1770-1784)
sidesteps this for XML by stamping the parent value onto every backend dict. _is_private
(:1810-1814) returns self.private unconditionally, because the hierarchical store does not use
object_store_id to dispatch.
HierarchicalObjectStore inherits the base object_store_ids() → [] (:376-383), which makes
the shareable= API filter a no-op under it (see below).
The write-side gate
ObjectStorePopulator (:2159-2189) is where a private store refuses shareable data:
concrete_store = self.object_store.create(dataset)
if concrete_store.private and require_shareable:
raise ObjectCreationProblemSharingDisabled()
The check reads concrete_store.private directly, not through is_private() — this matters for
#Where A and B Meet — and Where They Don’t.
Sharp edge: the two entry points have opposite defaults. set_object_store_id defaults
require_shareable=False (:2173); set_dataset_object_store_id defaults True (:2177). Five
call sites in lib/:
| Call site | require_shareable |
|---|---|
lib/galaxy/jobs/__init__.py:1843, :1956, :1964 | explicit, from requires_shareable_storage |
lib/galaxy/model/__init__.py:5588 (HistoryDatasetAssociation.set_skipped) | omitted → False |
lib/galaxy/model/deferred.py:183 (deferred materialization) | omitted → True |
The last two land on opposite sides purely by which overload they happened to call. Neither looks deliberate.
ObjectCreationProblemSharingDisabled.client_message (:2143-2157) is consumed at exactly one
place — lib/galaxy/jobs/runners/__init__.py:212 — so a violation surfaces as a failed job with
an admin-legible message, not an API 4xx.
Read and share-side gates
Dataset.shareable / ensure_shareable() are consulted at:
| Site | Blocks |
|---|---|
lib/galaxy/model/security.py:896-903 | set_all_dataset_permissions — for a non-new dataset in a non-shareable store, the incoming permission set must be exactly one PRIVATE access role |
lib/galaxy/model/security.py:936 | set_dataset_permission when touching DATASET_ACCESS |
lib/galaxy/model/security.py:978 | privately_share_dataset — despite the name, private stores still forbid it (“privately share” = share with a restricted set) |
lib/galaxy/model/security.py:1202 | make_dataset_public |
lib/galaxy/model/__init__.py:6297-6298 | to_library_dataset_dataset_association — raises a bare Exception, not a MessageException |
lib/galaxy/webapps/galaxy/controllers/dataset.py:261 | legacy controller display path |
API filtering at lib/galaxy/webapps/galaxy/services/history_contents.py:1095-1099 maps
shareable= onto object_store_ids(private=not shareable), but guards with if object_store_ids: —
an empty result silently disables the filter rather than returning nothing.
API and client surface
GET /api/datasets/{id}/storage → DatasetsService.show_storage
(lib/galaxy/webapps/galaxy/services/datasets.py:450-499) reports private (:467, via
is_private), shareable (:489), and relocatable (:488). The response model is
DatasetStorageDetails at services/datasets.py:146-177 — it lives there, not in schema/schema.py.
can_change_object_store_id (lib/galaxy/model/security.py:629-643) is the relocation gate, and
deliberately uses actual sharing (any HDA in another user’s history) rather than either privacy
notion — its comment says a dataset “kept private in a sharable object store” should still allow the
swap.
Client: ObjectStore/ObjectStoreRestrictionSpan.vue:14 carries the user-facing sentence
(“stored on storage restricted to a single user… cannot be shared, published, or added to Galaxy
data libraries”); ObjectStore/badgeMessages.ts:2-4 and ObjectStoreBadge.vue:64-65 render
restricted as a disadvantage and user_defined as neutral.
Relocation
lib/galaxy/managers/dataset_storage_operations.py:264-267 computes
is_privacy_downgrade_for_target (source private, target not). It feeds
preview.privacy_downgrade_count and produces advisory copy (:592-595) — a warning, not a
block. _is_private_for_object_store_id (:299-304) duck-types a SimpleNamespace proxy to ask
is_private about a bare store id.
Layer B — User-Defined BYOS Object Stores
Identity and model
- Scheme
USER_OBJECTS_SCHEME = "user_objects://"(objectstore/__init__.py:76); predicateis_user_object_store(:87-88) is a plainstartswith UserObjectStoremodel —lib/galaxy/model/__init__.py:12597-12662, tableuser_object_store, extendsHasConfigTemplate(:12582-12594) →HasConfigSecrets+HasConfigEnvironment;User.object_storesat:903- Migration
c14a3c93d66a_add_user_defined_object_stores.pyaddstemplate_id,template_version,template_definition,template_variables,template_secrets, and the usual lifecycle columns. Noprivatecolumn.
Shared config-template machinery
lib/galaxy/managers/_config_templates.py (665 lines) is genuine shared abstraction between
object_store_instances.py and file_source_instances.py — the latter backing user file sources
(see Component - DRS Support). object_store_instances.py:53-75 imports 26 names from it:
payload models, test targets, secret/vault lifecycle (recover_secrets, update_instance_secret,
upgrade_secrets, purge_template_instance), template lifecycle (sort_templates,
save_template_instance), and OAuth2 helpers. The model-layer counterpart is HasConfigTemplate,
shared by UserObjectStore and UserFileSource, keyed by secret_config_type.
This is the closest structural sibling to Component - User-Defined Tools — same “user brings a definition, admin sets the envelope” shape.
Configuration models
lib/galaxy/objectstore/templates/models.py:
ObjectStoreTemplateType(:37) —aws_s3 | azure_blob | boto3 | disk | generic_s3 | onedata | rucio | irodsObjectStoreConfiguration(:462-472) — a pydantic discriminated union ontype(see Dependency - Pydantic Discriminated Unions)- every member is a
StrictModel—lib/galaxy/util/config_templates.py:72-73,ConfigDict(extra="forbid", coerce_numbers_to_str=True)
No member has a private field, and extra="forbid" means an admin cannot add one via a
template. This is load-bearing.
Resolution
user_objects://<uuid> → live ConcreteObjectStore:
BaseUserObjectStoreResolver.resolve_object_store_uri(objectstore/__init__.py:99-109)UserObjectStoreResolverImpl.resolve_object_store_uri_config(managers/object_store_instances.py:384-397) — load the row, recover secrets from the vault, prepare environment, sort templates, expand configurationconcrete_object_store(...)(objectstore/__init__.py:2013-2043) — builds an ad-hocGalaxyConfigAdapter(hardcodingstore_by="uuid",enable_quotas=False) and constructs the backend fromobject_store_configuration.model_dump()DistributedObjectStore._resolve_backend(:1613-1619) is the hook — so BYOS only works under aDistributedObjectStore(type_to_object_store_class,:1880-1882)validate_selected_object_store_id(:1706-1723) — BYOS ids bypass the selection allowlist; the UUID is matched againstuser.object_storesinstead
Job-directory serialisation runs through serialize_static_object_store_config (:1824) and
user_object_store_configuration_to_config_dict (:1381-1389), driven from
lib/galaxy/jobs/__init__.py:2611-2618.
Quota
BYOS is quota-exempt: lib/galaxy/quota/__init__.py:365-367 returns False from is_over_quota
for a user_objects:// job, complemented by QuotaSourceMap.get_quota_source_info
(objectstore/__init__.py:2091-2097) and QuotaModel(source=None, enabled=False) in _to_model.
API and UI
lib/galaxy/webapps/galaxy/api/object_store.py exposes GET /api/object_stores (requires
selectable=true) plus CRUD/test/purge on /api/object_store_instances.
UserConcreteObjectStoreModel (managers/object_store_instances.py:82-91) extends
ConcreteObjectStoreModel with template provenance.
Selenium coverage: NavigatesGalaxy.create_object_store_template
(lib/galaxy/selenium/navigates_galaxy.py:3150-3173), sharing _fill_configuration_template with
the file-source equivalent — see Component - E2E Tests - Writing.
Where A and B Meet — and Where They Don’t
BYOS stores are reported as private but are not mechanically private. Layer A is never engaged.
_to_modelhardcodesprivate=Trueon the API model (managers/object_store_instances.py:362) and passesTruetoserialize_badges(:337-343), so every BYOS instance carriesrestricted+user_definedbadges.- The runtime store is built from
object_store_configuration.model_dump(), and no configuration variant has aprivatefield. - So
ConcreteObjectStore.__init__evaluatesconfig_dict.get("private", DEFAULT_PRIVATE)→False. - Therefore, for a dataset in a BYOS store:
Dataset.shareable→True;ObjectCreationProblemSharingDisabledis never raised;make_dataset_public,privately_share_dataset, andset_all_dataset_permissionsall pass their guards. - Nothing fills the gap. Repo-wide,
is_user_object_storehas exactly three callers outsideobjectstore/__init__.py— quota, job-directory URI collection, and an import. No permission code path special-cases BYOS.
Consequence: GET /api/object_store_instances reports private: true for an instance while
GET /api/datasets/{id}/storage reports private: false for a dataset sitting in it — and the
enforcement paths consult the second.
Framed safely: BYOS privacy today is presentational and social (the store is bound to one user’s
credentials; the UI badges it restricted), not mechanical. Whether a user can share data out of
their own S3 bucket is governed by the ordinary permission system, exactly as if the data sat on the
default disk.
Making it mechanical would mean adding private to ObjectStoreConfiguration or injecting it at
concrete_object_store — but that immediately trips ObjectCreationProblemSharingDisabled for any
user whose default permissions are public, which is the default. Per-store permission defaults do
not exist today.
Corroboration: test/integration/objectstore/test_per_user.py (487 lines, the canonical BYOS
suite) contains zero occurrences of “private” or “shareable”. Conversely
test_private_handling.py — the canonical Layer A suite — only ever configures an
admin-side private="true" store.
Config Surface
Admin, object_store_conf.xml / .yml — selected via object_store_config_file
(config_schema.yml:1200-1207) or embedded via object_store_config (:1209-1222). XML uses a
private="true" attribute; YAML a private: true key. The sample’s own prose
(object_store_conf.sample.yml:486-490, mirrored at object_store_conf.xml.sample:314-318) presents
privacy as an option with costs:
…it cannot be used in public datasets, shared between users, etc.. This is for example purposes - you may very well not want scratch storage to be defined as private as it prevents a lot of regular functionality…
The mandatory pairing is new_user_dataset_access_role_default_private
(config_schema.yml:3051-3059, default false). A private store is only usable if new users’
datasets default to private permissions — otherwise every job fails at ObjectStorePopulator.
test_private_handling.py tests both polarities and asserts exactly this.
Admin, object_store_templates.yml — object_store_templates_config_file
(config_schema.yml:661-667) or embedded object_store_templates (:669-674).
user_config_templates_use_saved_configuration (:691-700, enum
fallback | preferred | never, default fallback) governs whether a stored template_definition
or the current catalog wins when an admin has changed templates; it is shared with file sources.
Tests
Layer A
| File | Covers |
|---|---|
test/unit/objectstore/test_objectstore.py:368-392 (test_mixed_private) | object_store_ids(private=…) filtering; hierarchical propagation |
test/unit/objectstore/test_badges.py | badge serialisation incl. restricted |
test/integration/objectstore/test_private_handling.py | make_dataset_public → HTTP 400 / code 400008; job errors when default permissions are public |
test/integration/objectstore/test_private_handling_library_imports.py | public-library input + private preferred store still lands outputs privately |
test/integration/objectstore/test_bulk_storage_operations.py:698 | the privacy-downgrade advisory warning |
test/integration/objectstore/test_changing_objectstore.py:37 | relocation under private-by-default permissions |
Layer B
| File | Covers |
|---|---|
test/integration/objectstore/test_per_user.py | 9 tests — create/update/use, bad template id and version, secrets, upgrades, quota exemption. Needs a distributed parent store |
test/unit/objectstore/test_serializing_user_object_stores.py | job-directory serialisation of user_objects:// |
test/unit/data/test_quota.py:126 | usage accounting for a BYOS id |
test/integration/test_scripts_pgcleanup.py:252-315 | pgcleanup cannot resolve user_objects:// — a documented operational limitation |
Coverage gap: nothing asserts the privacy semantics of a BYOS store. No test exercises
Dataset.shareable, make_dataset_public, or ObjectCreationProblemSharingDisabled against a
user_objects:// dataset — which is precisely why the mismatch above goes unnoticed.
Extension Points
- New privacy-bearing backend — subclass
ConcreteObjectStore;private/_is_privatecome free. For XML support, rememberparse_private_from_config_xmlin yourparse_xml(pattern:DiskObjectStore.parse_xml,:958). YAML needs nothing. - New BYOS template type — add a
LiteraltoObjectStoreTemplateType, a…TemplateConfiguration/…ConfigurationStrictModelpair, both discriminated unions (templates/models.py:445-472), and an example catalog. Ensuretype_to_object_store_class(:1859-1917) can build it and the class is aConcreteObjectStore(asserted at:2038). - Making BYOS genuinely private — add
privateto each…Configuration, or inject atuser_object_store_configuration_to_config_dict(:1381) /concrete_object_store(:2013). Weigh thenew_user_dataset_access_role_default_privateinteraction — you would need per-store permission defaults. - New “meets storage privacy” check — go through
Dataset.shareable/ensure_shareable(), neverobject_store.is_private()directly.ensure_shareablegives the standardMessageException+ error code400008for free (see Component - UI Error Handling).
Known Issues and Sharp Edges
- BYOS reports
private: truebut is not private. Two API endpoints disagree; enforcement follows the non-private one. Untested in either direction. - Asymmetric
require_shareabledefaults (:2173vs:2177) — the two non-job callers omit the flag and land on opposite sides as a result. NestedObjectStore._is_privatefails open (:1340-1341) — an unresolvable object reads as shareable.shareable=history-contents filter silently no-ops on hierarchical/concrete top-level stores.- Hierarchical privacy agreement is an
assert(:1752-1755) — disabled underpython -O, and raisesAssertionErrorrather than aConfigurationErroron misconfiguration. to_library_dataset_dataset_associationraises a bareException(model/__init__.py:6298) → surfaces as a 500, unlike every otherensure_shareablesite.dataset_is_private_to_a_userdocstring is wrong — copied from its sibling, says “the current user’s private role” for a method with no user context (security.py:1158-1163).requires_shareable_storagereloads permissions in the job layer — acknowledged in-code as avoidable if computed at job creation.- pgcleanup cannot resolve
user_objects://backends. - API layer calls a private manager method —
api/object_store.py:87reachesobject_store_instance_manager._to_model(...). - Privacy downgrade on relocation is a warning, not a block.
Related Notes
- Component - Data Fetch — upload/import is a major producer of datasets needing an
object_store_id; therequire_shareablegate fires on upload jobs - Component - Workflow API — invocations carry
preferred_object_store_id(and split output/intermediate stores), the outermost tier of the preference cascade - Component - User-Defined Tools — closest structural sibling; same enforced-vs-convention tension
- Component - DRS Support — user file sources share
_config_templates.pyandHasConfigTemplate - Dependency - Pydantic Discriminated Unions — why a
privatekey cannot be smuggled through a template - Component - UI Error Handling —
CANNOT_SHARE_PRIVATE_DATASET_MESSAGEand error code400008 - Component - API Tests —
DatasetPopulator.selectable_object_stores()/create_object_store() - Component - E2E Tests - Writing — the BYOS preferences UI surface