OmniBioAI ModelHub is a production-grade experiment tracking and model lifecycle management system for AI/ML models within the OmniBioAI ecosystem — purpose-built for biomedical AI plugins.
It provides:
- Experiment tracking — log params, metrics, tags from training runs
- Step-indexed metric history with sparkline visualization
- Immutable model versioning (write-once)
- Cryptographic integrity verification (SHA256)
- Staged promotion workflows (latest → staging → production)
- Alias management with full audit trail
- MySQL-backed run storage with filesystem fallback
- Plugin-first design —
PluginRunClientfor TES container environments - Local-first, cloud-ready storage abstraction
- REST API (FastAPI) + CLI (
omr) + Python SDK - IAM-gated reads and writes — every non-informational endpoint (mutations and reads alike) requires a valid JWT (via
omnibioai-iam-client) carrying themodel.usepermission, whenAUTH_ENABLED=true; the registry verifies this itself rather than relying solely on the API Gateway - One-click Hugging Face push — package and upload a registered model version straight to the Hub
- Usage metering + cross-service audit — registration events are emitted to the platform usage pipeline and to the security-audit service, in addition to this repo's own local
promotions.jsonltrail
The registry is implemented as a standalone Python library (package name: omnibioai-model-registry, CLI entrypoint: omr) and ships a self-contained FastAPI service.
- ✅ Experiment tracking (
RunLogger,PluginRunClient) - ✅ MySQL-backed metric + param storage
- ✅ Immutable and verifiable model storage
- ✅ Audit-ready promotion workflow
- ✅ 22 REST endpoints (tracking + registry + governance + Hugging Face push)
- ✅ 13 CLI commands
- ✅ IAM-gated reads and writes (
model.usepermission,omnibioai-iam-client, enforced independently at the registry) - ✅ Usage metering + cross-service audit emission
- ✅ ModelHub UI with Experiments tab + metric sparklines
- ✅ Local-first, cloud-ready design
- ✅ Organization ownership recorded (server-derived from verified IAM identity) and enforced on every model-bearing read/write route, including Hugging Face push; see Organization Ownership and Enforcement
- ✅ Run-tracking data (
omr_runs/omr_params/omr_metrics/omr_tags/omr_version_tags) organization-isolated too; see Tracking-Data Organization Isolation - ✅ Filesystem path traversal closed for every task/model/version/alias/run/metric identifier, centrally enforced; see Filesystem Path Safety
- ✅ Legacy/unowned model ownership resolvable via a dedicated, non-role-based IAM permission, self-scoped and write-once; see Legacy Ownership Resolution
Biomedical AI requires:
- Reproducibility
- Auditability
- Governance
- Offline / air-gapped deployment
- Cross-infrastructure execution parity
Traditional ML tooling often assumes cloud-first infrastructure, mutable artifacts, and weak provenance guarantees.
OmniBioAI ModelHub is designed differently.
It treats AI models as scientific artifacts that must be immutable, verifiable, and reproducible across environments.
Two clients cover the two primary execution contexts.
Writes directly to the registry root on the local filesystem. No network required.
from omnibioai_model_registry import RunLogger, register_model
with RunLogger(task="celltype_sc", model_name="human_pbmc") as run:
run.log_params({"lr": 0.001, "epochs": 50, "batch_size": 32})
for epoch, acc in enumerate(training_curve):
run.log_metric("accuracy", acc, step=epoch)
run.log_metric("val_loss", loss, step=epoch)
run.set_tag("team", "bioml")
register_model(
task="celltype_sc",
model_name="human_pbmc",
version="2026-06-14_001",
artifacts_dir="/path/to/artifacts",
metadata={"lineage": {"run_id": run.run_id}},
)Filesystem layout produced by RunLogger:
{registry_root}/tasks/{task}/models/{model_name}/runs/{run_id}/
params.json # {"lr": 0.001, "epochs": 50}
tags.json # {"team": "bioml"}
metrics/
accuracy.jsonl # one JSON record per step
val_loss.jsonl
Posts metrics and params to the ModelHub REST API. Designed for training jobs running inside TES-scheduled containers that cannot access the registry filesystem directly.
import os
from omnibioai_model_registry import PluginRunClient
with PluginRunClient(
task="celltype_sc",
model_name="human_pbmc",
registry_url=os.environ["MODEL_REGISTRY_BASE_URL"],
) as run:
run.log_params({"lr": 0.001})
run.log_metric("accuracy", 0.95, step=0)
run.set_tag("plugin_version", "1.2.3")Both clients share the same log_params / log_metric / set_tag interface. The storage backend is the only difference.
OmniBioAI follows a four-plane architecture:
| Plane | Responsibility |
|---|---|
| Control Plane | UI, registries, metadata, governance |
| Compute Plane | Workflow execution, HPC/cloud adapters |
| Data Plane | Artifacts, datasets, outputs |
| AI Plane | Reasoning, RAG, agents, interpretation |
The ModelHub belongs to the Control Plane and provides AI artifact governance, deterministic inference references, promotion and audit workflows, and infrastructure-independent model resolution.
Each model version is write-once: no overwrites, no silent mutation, full historical trace. This guarantees scientific reproducibility.
Every model package includes a SHA256 manifest (sha256sums.txt) that hashes the package contents (excluding itself). This enables bit-level reproducibility, tamper detection, and trustworthy deployment in regulated environments.
Each model stores structured metadata via model_meta.json:
- training code version (git commit)
- dataset reference (e.g., DVC / object store ref)
- hyperparameters and preprocessing
lineage.run_idlinking back to the originating tracking run
Models move through controlled stages:
latest → staging → production
All promotions are explicit, append-only, and audited (audit/promotions.jsonl).
v0.2.0 supports a local filesystem backend (localfs) with a MySQL-backed tracking layer. S3 / Azure Blob backends are on the roadmap.
omnibioai-model-registry/
├── omnibioai_model_registry/
│ ├── api.py
│ ├── config.py # Settings incl. AUTH_ENABLED/JWT_SECRET/IAM_URL
│ ├── refs.py
│ ├── errors.py
│ ├── run.py # RunLogger — filesystem-based tracking
│ ├── plugin_client.py # PluginRunClient — HTTP-based tracking for TES plugins
│ ├── db.py # MySQL connection + table bootstrap
│ ├── tracking.py # Pure-SQL tracking functions
│ ├── auth.py # IAM integration — require_auth/require_write_auth,
│ │ # model.use permission via omnibioai-iam-client
│ ├── audit_client.py # Fire-and-forget AuditClient — POSTs to AUDIT_URL
│ │ # (security-audit), separate from audit/'s local trail
│ ├── hf_routes.py # Hugging Face push — POST /v1/hf/push, status, settings
│ ├── usage_emit.py # Usage-metering wrapper around omnibioai-usage-client
│ ├── ownership.py # Phase 2A — write-once model ownership.json,
│ │ # legacy backfill (see Organization Ownership)
│ ├── storage/
│ ├── package/
│ ├── audit/ # Local audit trail — audit/promotions.jsonl
│ ├── cli/
│ └── service/
├── frontend/
│ └── omnibioai-model-registry-ui/ # ModelHub UI (React + TypeScript)
├── tests/
├── pyproject.toml
└── README.md
Registered models follow a strict, portable structure:
<OMNIBIOAI_MODEL_REGISTRY_ROOT>/
tasks/<task>/models/<model_name>/
versions/<version>/
model.pt
model_genes.txt
label_map.json
model_meta.json
metrics.json
feature_schema.json
sha256sums.txt
aliases/
latest.json
staging.json
production.json
audit/
promotions.jsonl
ownership.json
ownership.json (Phase 2A) is write-once and model-level, not per-version,
and enforced on every model-bearing route (Phase 2B) — see
Organization Ownership and Enforcement.
This guarantees deterministic loading, integrity validation, and cross-environment portability.
export OMNIBIOAI_MODEL_REGISTRY_ROOT=~/local_registry/model_registrypip install -e .Verify:
python -c "import omnibioai_model_registry as m; print('OK', m.__file__)"
omr --helppip install build
python -m buildArtifacts are written to dist/:
dist/omnibioai_model_registry-0.2.0-py3-none-any.whldist/omnibioai_model_registry-0.2.0.tar.gz
Install the wheel:
pip install dist/*.whl11 commands covering the full model lifecycle.
omr register \
--task celltype_sc \
--model human_pbmc \
--version 2026-06-14_001 \
--artifacts /tmp/model_pkg \
--set-alias latestomr resolve --task celltype_sc --ref human_pbmc@latestomr promote --task celltype_sc --model human_pbmc --version 2026-06-14_001 --alias productionomr verify --task celltype_sc --ref human_pbmc@productionomr show --task celltype_sc --ref human_pbmc@production --jsonomr list --task celltype_scomr metrics --task celltype_sc --ref human_pbmc@latestomr aliases --task celltype_sc --model human_pbmcomr tag --task celltype_sc --ref human_pbmc@2026-06-14_001 --key team --value biomlomr stage --task celltype_sc --model human_pbmc --version 2026-06-14_001 --stage productionValid stages: none, staging, production, archived.
omr compare --task celltype_sc --model human_pbmc --versions 2026-02-14_001 2026-06-14_001omr migrate-ownership --jsonDeterministic, repeatable, additive-only: writes an explicit
status="legacy_unowned" ownership.json for every pre-existing model
that has none yet. Never guesses an organization. Safe to re-run. As of
Phase 2B, a legacy_unowned model is denied for every caller through
every enforced route, not just left unassigned — see
Organization Ownership and Enforcement.
omr resolve-ownership --task celltype_sc --model human_pbmc --org-id org-abc123Assigns real ownership to a status="legacy_unowned" model. Write-once
and idempotent for the same --org-id; refuses to reassign an
already-owned model. The CLI has no IAM/JWT identity of its own, so
--org-id is explicit here (same trust boundary as
omr register --org-id) — the HTTP equivalent,
POST /v1/ownership/resolve, has no such flag at all and always
self-scopes to the caller's own verified organization. See
Legacy Ownership Resolution.
from omnibioai_model_registry import register_model, resolve_model, promote_model
register_model(
task="celltype_sc",
model_name="human_pbmc",
version="2026-06-14_001",
artifacts_dir="/tmp/model_pkg",
metadata={
"framework": "pytorch",
"model_type": "classifier",
"provenance": {
"git_commit": "abc123",
"training_data_ref": "s3://bucket/datasets/pbmc_v1",
"trainer_version": "0.2.0",
},
},
set_alias="latest",
actor="manish",
reason="initial training",
)
# Resolve by alias (or version)
path = resolve_model("celltype_sc", "human_pbmc@latest", verify=True)
print("Resolved model dir:", path)
# Promote to production
promote_model(
task="celltype_sc",
model_name="human_pbmc",
alias="production",
version="2026-06-14_001",
actor="manish",
reason="validated metrics",
)pip install -e .
uvicorn omnibioai_model_registry.service.app.main:app --host 0.0.0.0 --port 8095Health check:
curl -s http://127.0.0.1:8095/health | python -m json.toolRegistry
| Method | Path | Description |
|---|---|---|
| POST | /v1/register | Register a model version |
| GET | /v1/resolve | Resolve a model reference to a path |
| POST | /v1/promote | Promote a version to an alias |
| POST | /v1/verify | Verify SHA256 integrity |
| GET | /v1/show | Return model_meta.json for a ref |
| GET | /v1/models | List all registered model versions |
| POST | /v1/ownership/resolve | Resolve a legacy_unowned model's ownership (requires model.resolve_ownership, not model.use — see Legacy Ownership Resolution) |
Tracking (requires MySQL — HTTP 503 if DB_HOST is unset)
| Method | Path | Description |
|---|---|---|
| POST | /v1/runs/log-metric | Log a single metric point |
| POST | /v1/runs/log-param | Log a single parameter |
| POST | /v1/runs/log-batch | Log metrics, params, and tags |
| GET | /v1/runs/get | Fetch a full run snapshot |
| GET | /v1/runs/list | List runs for a (task, model) |
Governance
| Method | Path | Description |
|---|---|---|
| GET | /v1/aliases | List all aliases for a model |
| GET | /v1/metrics | Return version metrics + step history from DB/JSONL |
| GET | /v1/compare | Compare metrics across two or more versions |
| GET | /v1/artifacts | List files in a version package with SHA256 + sizes |
| PUT | /v1/tags | Set a tag on a model version |
| POST | /v1/versions/patch | Patch description or tags on a version |
| POST | /v1/stage | Set lifecycle stage (none/staging/production/archived) |
| GET | /v1/auth/status | Report whether auth is enabled and, if so, the caller's verified identity |
Hugging Face
| Method | Path | Description |
|---|---|---|
| POST | /v1/hf/push | Package a registered model version and push it to the Hugging Face Hub |
| GET | /v1/hf/push/status/{job_id} | Poll an async push job's status |
| GET | /v1/hf/settings | Whether a default HF_TOKEN/namespace is configured |
POST /v1/hf/push accepts an explicit token in the request body, falling
back to the HF_TOKEN env var if omitted — never required to be the
caller's own credential. See Authentication below for
the gate on all mutating routes above, including these three.
When DB_HOST is set, the service bootstraps five tables on startup:
omr_runs — run lifecycle (run_id, status, started_at, finished_at,
organization_id, ownership_status) [Phase 2C]
omr_params — key/value params per run
omr_metrics — step-indexed metric values per run
omr_tags — key/value tags per run
omr_version_tags — key/value tags per model version
organization_id/ownership_status on omr_runs (Phase 2C) are added
via an idempotent ALTER TABLE ... ADD COLUMN migration run on every
startup (db.py's _ALTER_DDL, guarded against MySQL error 1060 so
re-running is always a safe no-op) — see
Tracking-Data Organization Isolation
below for the full design and legacy-row migration reasoning.
omr_params/omr_metrics/omr_tags/omr_version_tags have no schema
change; they inherit their organization boundary from omr_runs/
ownership.json respectively, never a duplicated column.
Environment variables:
export DB_HOST=localhost
export DB_PORT=3306
export DB_USER=omr
export DB_PASSWORD=secret
export DB_NAME=model_registryWhen DB_HOST is absent, the service runs in filesystem-only mode. Tracking endpoints return HTTP 503; all registry and governance endpoints remain fully functional.
Off by default; every non-informational endpoint — both mutations
(register, promote, tags, versions/patch, stage, the hf/*
routes, runs/log-*) and reads (resolve, verify, show, models,
runs/get, runs/list, metrics, aliases, compare, artifacts,
hf/push/status/{job_id}) — is IAM-gated when explicitly enabled:
export AUTH_ENABLED=true
export JWT_SECRET=... # HS256 fallback secret, matches omnibioai-auth's SECRET_KEY
export IAM_URL=http://auth-service:8001AUTH_ENABLED=false (the default) runs the service in open mode — no
token required, every call attributed to a synthetic system actor. When
enabled, require_auth/require_write_auth (auth.py) verify the
presented JWT via omnibioai-iam-client's AsyncIAMClient.get_user()
(RS256/JWKS-or-HS256 signature check + revocation check against
omnibioai-auth, no local JWT decoding of its own) and require the
model.use permission — the same IAM pattern omnibioai-lims and
omnibioai-api-gateway use. The registry performs this verification
itself, independently of the API Gateway — it never trusts
gateway-injected identity headers (X-Organization-ID, X-Team-ID,
X-User-ID, X-User-Email, or similar) as a substitute for a verified
Bearer token. The Gateway remains a real enforcement layer of its own
(it authenticates and policy-checks every request before forwarding),
but the registry no longer depends on it as the only layer — a
request that reaches the service directly is now held to the same
standard as one that arrives via the Gateway. GET /v1/auth/status,
GET /v1/hf/settings, and GET /health remain public — they carry no
registry data, only service/mode metadata.
Scope note: authorization is still permission-based first —
model.use remains the existing flat, non-resource-scoped permission,
unchanged and not redesigned by Phase 2B. On top of that, every
model-bearing read/write route now independently enforces organization
ownership (see
Organization Ownership below):
holding model.use is necessary but no longer sufficient — the caller's
verified org_id must also match the model's recorded owner (or, for
omr_* run-tracking routes as of Phase 2C, the run's own recorded
owner — see
Tracking-Data Organization Isolation).
Every newly registered model now has a durable, server-derived
organization-ownership record, stored as ownership.json at the model
root (tasks/<task>/models/<model_name>/ownership.json — one per model,
not per version; versions inherit their parent model's ownership by
construction, they carry no independent ownership field). This is
not part of the SHA256-hashed version manifest (that only covers
files inside a version directory) — it is a separate, model-level,
write-once file next to aliases/ and audit/.
- Source of truth: the filesystem. MySQL's
omr_*tables are an optional,DB_HOST-gated side-store for experiment-tracking metrics only (see below) and never represent models/versions themselves — the service is fully functional withDB_HOSTunset, so ownership cannot depend on MySQL being configured. There is exactly one source of truth for ownership; it is not duplicated into MySQL or intomodel_meta.json. - Where
organization_idcomes from: only the caller's already-verified IAM identity (UserContext.org_id, resolved byrequire_write_auth_with_contextafter JWT verification). Never from the request body, query/path parameters, or any header (X-Organization-ID,X-Team-ID,X-User-ID,X-User-Email, or a client-suppliedorganization_idkey inside the free-formmetadatadict) — none of those are ever consulted. - Write-once: the very first successful registration of a given
task/model_nameestablishes ownership permanently. Every later version registered under that same model inherits the existing record unchanged; ownership is never reassigned by a subsequent write. As of Phase 2B, a different org's attempt to register a version under an existing model is also actively denied (not just ignored) — see below. - Legacy models: any model that already had a registered version
before this phase shipped (or is otherwise touched for the first time
post-Phase-2A with no
ownership.jsonyet) is recorded asstatus="legacy_unowned"withorganization_id=null— never guessed from actor strings or assigned to whichever org happens to touch it next. Resolving these into real ownership is an explicit, deferred, administrator/manual-assignment step (Phase 2B+), not something this phase attempts. Runomr migrate-ownershipto proactively backfill this explicit marker for every legacy model in one deterministic, repeatable, additive-only pass (safe to re-run). - CLI:
omr register --org-id <id>lets an operator assign ownership explicitly for a brand-new model when registering out-of-band (the CLI has no JWT/IAM identity of its own); it has no effect on an already-owned or legacy model. POST /v1/register's response now includes server-derivedorganization_idandownership_statusfields ("owned","unowned", or"legacy_unowned"). No other endpoint's response shape changed in this phase.
Phase 2B — enforcement. Every model-bearing read/write route above
now independently checks
verified_user.org_id == model_owner.organization_id via
ownership.check_model_ownership(), the single centralized decision
function every route (or the underlying ModelRegistry method it goes
through) calls before touching filesystem state:
- Reads:
resolve,verify,show,models(list — filtered during the same filesystem walk that already reads each entry, not fetched-then-hidden in the response),compare,artifacts,aliases,metrics. - Writes:
register(a version under an existing model requires belonging to its owner; a brand-new model is unaffected — see Write-once above),promote,tags,versions/patch,stage. POST /v1/hf/push: resolves the model/version and checks ownership before any Hugging Face API call — a cross-org push is denied before an artifact is ever read, let alone uploaded.GET /v1/hf/push/status/{job_id}is org-scoped too as of Phase 2D — the job itself now carries theorganization_idof whoever started the push (recorded atPOST /v1/hf/pushtime), and polling status requires the same match; previously anymodel.useholder could poll any job_id (a documented, bounded enumeration/status-leak gap, never a secret-disclosure one — job_id is an unguessable uuid4 and the response never contains the HF token — but explicitly flagged since Phase 1 as "deferred to the tenant-isolation phase").- Denial shape: anti-enumerating by construction — a cross-org
caller gets the exact same "not found" response (same status code,
same message) a genuinely nonexistent model would return from that
route.
legacy_unownedmodels are denied for every caller, including one with no org context at all — never silently claimed by whoever touches them next; the only way out of that state is the explicit, separately-permissioned resolution workflow — see Legacy Ownership Resolution below. - Audit:
register_model/promote_model/set_tag/patch_version/set_stage/push_to_hfevents all carryorganization_iddirectly in their metadata now, not only inferable by correlating with a separatemodel_access_*event.
Not yet implemented:
- Resource-scoped
model.use(authorization is still ownership-check-plus-flat-permission, not a redesigned permission model). Not needed for legacy-ownership resolution either — see Legacy Ownership Resolution, which uses a second, independent, equally-flat permission rather than requiringmodel.useitself to change shape. - A
platform/public model namespace; model deletion.
Path traversal — fixed, in a dedicated follow-up PR, not Phase 2D
itself. Phase 2D identified (but by its own explicit charter did not
fix, since it is independent of tenant isolation — it would exist in a
single-tenant deployment too) that task/model_name/version/alias
request fields had no ..-sequence sanitization anywhere in refs.py/
package/layout.py/storage/localfs.py. That gap is now closed — see
Filesystem Path Safety below for the full
design (a centralized allowlist validator, applied once in
package/layout.py, inherited by every consumer).
Turns Phase 2A's status="legacy_unowned" marker into an explicit,
permissioned, write-once, self-scoped resolution path — the
"no admin-reassignment workflow exists yet" gap every phase since 2A had
flagged and deliberately left unfilled, until now (with one remaining
external dependency — see below).
- The permission —
model.resolve_ownership, not a role. Gated by a dedicated IAM permission, checked completely independently ofmodel.use— holdingmodel.usealone gets403onPOST /v1/ownership/resolve. This is a deliberate choice, not an oversight: Phase 2D evaluatedUserContext.org_rolecontaining"org_admin"(omnibioai-auth's standard per-org admin role) as an alternative and rejected it, because checking a role name would have been a first, precedent-breaking exception to this codebase's own stated policy (auth.py's module docstring) of authorizing by permission, never by role.model.resolve_ownershipkeeps that policy intact — same mechanism asmodel.use(a name in the verified JWT'spermissionsclaim), just a second, narrower one. - Registered in omnibioai-auth. This permission is checked by this
service and now also exists in omnibioai-auth's permission catalog
(
app/core/permission_names.py), granted to that repo'sorg_adminrole — shipped as a separate follow-up in that repo (omnibioai-auth PR #57), the same way this repo has never unilaterally edited omnibioai-auth formodel.useeither. The endpoint is usable in any deployment withAUTH_ENABLED=true. Note:model.ownership.resolve(a name discussed early on) does not satisfy omnibioai-auth's own permission-name format (resource.action, a single dot) —model.resolve_ownershipdoes, and is the name actually used everywhere in this codebase. - Self-scoped only — no target-organization parameter, anywhere.
POST /v1/ownership/resolve's request body has noorganization_idfield; the CLI's--org-idis the one exception, and it exists only because the CLI has no IAM/JWT identity of its own at all (same established trust boundary asomr register --org-id). Over HTTP,organization_idis exclusively the caller's own verifiedUserContext.org_id— an authorized resolver can only ever resolve a legacy model to their own organization, never to an arbitrary one. This was the other design considered and rejected in Phase 2D: the existingmanage_all_orgsplatform-admin permission would have kept the "permission, not role" policy, but only by accepting a client-supplied targetorganization_idfor the first time ever in this ownership model — breaking the "organization_idnever client-supplied" invariant every phase since 2A has held. Rejected for that reason; not implemented. - Eligibility (
ownership.resolve_legacy_ownership()), checked against the persistedownership.jsonstatus only — never actor strings, headers, query parameters, model metadata, or filesystem paths:status="legacy_unowned"— the only eligible state. Becomesstatus="owned",organization_id=<the resolver's own org>.status="owned", already the resolver's own org — idempotent: returns the current state unchanged, not an error (already_resolved: truein the response). Repeating a resolution that already succeeded must be safe.status="owned", a different org — denied. Ownership is never reassigned, full stop, regardless of who's asking.status="unowned"— denied. This is a real, already-established state from the open/no-org dev-test mode (see Organization Ownership and Enforcement), not an orphaned record; resolving it into a specific org would silently narrow who can already access it (today, any other open-mode caller) — a materially different, out-of-scope operation.- No
ownership.jsonat all — the model doesn't exist; denied.
- Write-once and race-safe via a dedicated marker, not a second
ownership source of truth.
ownership.jsonfor a legacy model already exists on disk (unlike a brand-new model's registration), so the existing write-once primitive (LocalFS.write_once_text, an exclusiveos.link-based create) can't be used onownership.jsonitself to arbitrate a race — the file's already there. Instead, exactly one concurrent resolver'sorganization_idis decided viawrite_once_text()on a separate, dedicatedownership_resolution.jsonmarker; every racing caller — whether it created that marker or lost the race and read back the winner's — then computes and writes the identical finalownership.jsoncontent.check_model_ownership(), the single function every read/write route actually calls to decide access, never reads this marker; it exists purely to arbitrate the race, not as a competing authority. Verified with real concurrent threads, not just sequential calls. - Who may resolve: only a verified identity holding
model.resolve_ownership, self-scoped to their ownorg_id. Ordinarymodel.useholders — i.e. everyone who can read/write models day to day — cannot invoke this at all. - Audited on both success and failure, unlike most routes in this
file (which only audit success): actor identity, authenticated
organization_id, task/model, previous ownership status/org, resultingorganization_id/status,already_resolved, and a timestamp (added automatically byAuditClient) — no tokens or secrets in any field. - CLI:
omr resolve-ownership --task T --model M --org-id ORGmirrorsomr register --org-id's existing, already-accepted operator/administrator trust boundary — no new mechanism.
Extends organization-scoped enforcement from the filesystem model
registry (Phase 2A/2B, above) to the optional MySQL tracking layer
(omr_runs/omr_params/omr_metrics/omr_tags/omr_version_tags),
without a second, possibly-diverging source of truth for the same
question.
- Two independent ownership authorities, deliberately not merged.
ownership.json(Phase 2A) remains the sole authority for model ownership.omr_runs.organization_id(new) is the sole authority for run ownership — not derived fromownership.json, because a run'stask/model_namehas no required correspondence to any registered model at all: logging metrics/params during an experiment, then registering the resulting model afterward, is the whole point of this tracking layer, soownership.jsonfrequently won't exist yet (or ever) for a given run. Neither authority can override the other. - Where it lives:
organization_id/ownership_statuscolumns directly onomr_runs, populated write-once (firstlog-metric/log-param/log-batchcall for a givenrun_id) from the caller's verifiedUserContext.org_id— the exact same "record it once, never reassign" pattern Phase 2A established for models, just at the row level instead of a separate file.omr_params/omr_metrics/omr_tagsget no column of their own; they inherit their boundary via the existingrun_idforeign key.omr_version_tagsgets no column either — it's keyed directly by(task, model_name, version), a real model's version, so it's checked againstownership.jsoninstead (tracking.set_version_tag()/get_version_tags()now accept the sameenforce_ownership/requesting_org_idkwargs api.py'sModelRegistrymethods already do). - Enforced routes:
POST /v1/runs/log-metric|log-param|log-batch,GET /v1/runs/get|list.GET /v1/metrics's own run-history lookup is deliberately not run-ownership-gated — itsrun_idisn't caller-supplied, it's read out of the model's ownmodel_meta.json, reachable only becauseresolve_model()already enforced model ownership on(task, model_name); gating it a second time onomr_runs.organization_idtoo would incorrectly lock a model's legitimate owner out of their own run history whenever that run predates this migration (see below) or was logged before the model existed. - Legacy rows: every
omr_runsrow that existed before this migration getsorganization_id=NULL, ownership_status='legacy_unowned'automatically (MySQL applies a column'sDEFAULTto every existing row onALTER TABLE ... ADD COLUMN) — denied for every caller, including one with no org context, identical to Phase 2A/2B'slegacy_unownedmodel policy. Deliberately not backfilled by matching(task, model_name)againstownership.json: pre-Phase-2A this service enforced no uniqueness on those strings at all, so two different orgs' legacy runs could coincidentally share the sametask/model_name— a string-match backfill risked attributing one org's historical run data to a different org. Never guessed from theactorcolumn either. - Anti-enumeration: every denial (cross-org, legacy, or genuinely
missing) raises the exact same "Run not found"
ModelNotFoundalready used pre-Phase-2C for a missingrun_id— same status code, same message shape. GET /v1/runs/listfilters at the SQL layer (WHERE ... AND (organization_id/ownership_status ...)), not fetch-all-then-hide.- Concurrency: run creation is
INSERT IGNOREonrun_id'sPRIMARY KEY— atomic at the MySQL engine level, the same reasoningownership.json'sos.link-based write-once relies on, just via a different primitive. - Audit:
log_metric/log_param/log_batchnow emit audit events (none existed pre-Phase-2C) carryingorganization_id—log_param's event deliberately never includes the loggedvalue(caller-suppliedAny, unlike a metric's plain float) to avoid a param value that happens to be a secret ending up in the audit trail.
A standalone security hardening pass, independent of (and layered underneath, not instead of) the organization-ownership enforcement above — this section is about where on disk a request is allowed to touch, not which organization is allowed to touch it. This is not a HIPAA compliance claim or certification of any kind; it is a description of what this specific hardening pass does and does not cover.
The invariant: no user-controlled model/task/version/alias/run/
metric identifier may cause any filesystem operation to resolve outside
the configured OMNIBIOAI_MODEL_REGISTRY_ROOT, except the CLI (which
already has direct filesystem access and is not reachable over HTTP/JWT
at all — the same trust boundary Phase 2A's omr register --org-id
precedent already established).
Design — allowlist, not blocklist. path_safety.py's
safe_component() requires every identifier (task, model_name, version,
alias, run_id, metric_key) to match a strict character allowlist
(letters, digits, _, -, and interior .; must start and end with an
alphanumeric character; ≤128 characters) before it is ever joined into
a Path. Rejecting known-bad patterns one at a time (.., absolute
paths, encoded variants, backslashes, repeated separators, ...) is
inherently incomplete — there is always another variant; a component
that satisfies the allowlist is lexically incapable of forming a path
separator, a traversal sequence, or an absolute-path prefix on any
platform, which is a stronger guarantee than "reject .. after the
fact". Legitimate identifiers this system's own docs/tests already use
(human_pbmc, 2026-02-13_001, staging) are unaffected.
Where it's applied: once, centrally, inside package/layout.py's
path-builder functions — the single choke point every filesystem path in
this codebase is already constructed through (confirmed by inspection:
no other module builds a task/model/version/alias/run path by hand).
Every consumer (api.py, ownership.py, service/app/main.py,
run.py, cli/main.py) inherits this automatically, with no
per-call-site change needed. refs.py's parse_model_ref() also
validates model_name/selector directly, for an earlier, clearer error —
defense-in-depth, since every path it feeds into validates independently
anyway.
Defense-in-depth: path_safety.py's assert_contained() is a
second, independent layer at the handful of actual mutating/returning
operations in api.py (register_model's copy destination,
promote_model's alias write, resolve_model's returned version
directory) — re-verifies against the real, symlink-resolved filesystem
location, catching a hypothetical future bug in the allowlist itself,
not serving as the primary defense.
Symlink policy: symlink-aware, not symlink-hostile.
assert_contained() uses Path.resolve() (the same mechanism
config.py/run.py's own _resolve_registry_root() already uses for
registry_root itself), so a symlinked registry root — a plausible
legitimate deployment layout, e.g. network-mounted storage — is
followed, not rejected, as long as the real, final location is still
under the real, resolved root. A symlink placed inside the managed
tree that points outside the root is rejected, because its real
destination fails that same containment check. This service does not
itself create any symlinks anywhere in the tree it manages, so there is
no legitimate in-repo symlink layout to special-case beyond "the root
itself may be one."
Invalid-path error behavior: a rejected identifier raises
PathTraversalError (a ValidationError/ModelRegistryError), which
every route either already maps to a generic HTTP 400 via the existing
_handle_registry_error handler, or — for the couple of routes
(aliases, compare) that construct paths directly without a local
try/except — a dedicated FastAPI exception handler in
service/app/main.py catches directly, so the outcome is identical
either way. The error message names only the field (e.g. "Invalid model_name") — never the submitted value, a resolved filesystem path,
internal directory structure, or a stack trace.
artifacts_dir trust boundary (register_model()'s server-local
source directory, not an identifier — a fixed character allowlist
doesn't apply to it the way it does to task/model_name/version, since it
legitimately needs to be anywhere a training job wrote its output on the
server's own filesystem):
- Always enforced, no configuration:
artifacts_dirmay never resolve to a location inside the registry root itself. Without this, any authenticatedmodel.useholder could pointartifacts_dirat an already-registered version directory — including one belonging to a different organization — and have it copied into a brand-new registration they own, then read it back via their own, legitimately-authorized/v1/artifactsor/v1/resolve: a complete, silent bypass of every Phase 2A–2D ownership check through a path no ownership check ever inspects. Never a legitimate use case under any deployment, so this is a structural rule, not a policy choice. - Optional, operator-configured:
OMNIBIOAI_MODEL_REGISTRY_ARTIFACTS_ALLOWED_ROOTS(comma-separated absolute paths) further restricts which server-local directory treesartifacts_dirmay be under. Unset (the default) is intentionally unrestricted, preserving every existing deployment's behavior — this service has no established convention for where training-output directories live on a given server, so picking a mandatory default would be inventing a policy rather than enforcing one a deployment already has; operators who want this narrowed can opt in.
Not covered by this hardening pass (unchanged, and out of scope by
this PR's own charter): SQL parameterization (tracking.py's queries
were already parameterized, not string-built, and untouched here);
resource exhaustion / disk-fill from a very large artifacts_dir when
no allowlist is configured; anything about which organization may
access a given, validly-contained path — that remains entirely governed
by Phase 2A–2D's check_model_ownership()/check_run_ownership(),
unmodified and unweakened by this pass.
- Audit —
audit_client.py'sAuditClientfire-and-forget-POSTs an event toAUDIT_URL(the security-audit service) on register/promote/set_tag/set_stage, in addition to this repo's own localaudit/promotions.jsonltrail described under "Core Design Principles" above — two separate audit records, not one. - Usage metering —
usage_emit.pywrapsomnibioai-usage-clientto emit amodel.registerusage event on every registration (service="model-registry"), fail-open by design: a metering failure never blocks or fails the registration itself.
pip install -e ".[dev]"
pytest -qThe ModelHub is a control-plane component of OmniBioAI.
Companion repositories:
- omnibioai → AI-powered bioinformatics workbench
- omnibioai-tes → execution orchestration across local/HPC/cloud
- omnibioai-rag → reasoning and literature intelligence
- omnibioai-lims → laboratory data management
- omnibioai-workflow-bundles → reproducible pipelines
- omnibioai-sdk → Python client access
The ModelHub provides the AI artifact governance layer shared by all.
- Experiment tracking with
RunLogger+PluginRunClient - MySQL-backed run/metric/param/tag storage
- ModelHub UI with Experiments tab + metric sparklines
- Stage management (
none→staging→production→archived) - Alias listing, metric comparison, artifact browser endpoints
- HIPAA hardening Phase 1 — every non-informational read endpoint
now independently requires IAM authentication (
model.use), closing the previously-unauthenticated read-path gap; see Authentication. Tenant/organization isolation is explicitly not part of this phase. - HIPAA hardening Phase 2A — durable, server-derived organization
ownership for newly registered models; see
Organization Ownership.
Establishes who owns this model, server-controlled and
IAM-derived — it does not yet enforce that ownership anywhere.
model.useis unchanged (still flat, not resource-scoped). Legacy models remain explicitlylegacy_unowned, not guessed. - HIPAA hardening Phase 2B — turns Phase 2A's ownership record into
real enforcement; see
Organization Ownership and Enforcement.
Every model-bearing read/write route (including
POST /v1/hf/push) now independently denies a caller whose verifiedorg_iddoesn't match the model's owner, anti-enumerating by construction.legacy_unownedmodels remain unclaimed by design — still no admin-reassignment workflow (deliberately not invented in this phase either; needs its own product/audit decision).model.useremains unchanged (flat, not resource-scoped) — org enforcement sits alongside it, not inside a redesigned permission model. - HIPAA hardening Phase 2C — extends organization isolation to the
optional MySQL tracking layer; see
Tracking-Data Organization Isolation.
omr_runsgets its own independentorganization_id/ownership_statuscolumns (never derived fromownership.json— a run's task/model_name has no required correspondence to a registered model);omr_params/omr_metrics/omr_tagsinherit via therun_idFK;omr_version_tagsis checked againstownership.jsoninstead, since it IS a real model's version. Legacy rows (pre-migration) becomelegacy_unownedautomatically via the column'sALTER TABLEdefault, denied for everyone, never guessed fromactoror backfilled by string-matchingtask/model_nameagainstownership.json(a real, considered, and rejected option — see the section above for why). - HIPAA hardening Phase 2D — final tenant-isolation audit across
every model/run/version-tag/HF-push route from Phases 2A–2C (no
bypass, alternate-org-source, gateway-header-trust, mutation-after-
denial, or enumeration-oracle found), plus one real fix it surfaced:
GET /v1/hf/push/status/{job_id}is now org-scoped too (see Organization Ownership and Enforcement above). Audited legacy-ownership resolution end to end and deliberately did not build it in this phase — evaluated two candidate IAM designs and picked neither without a product decision; see Legacy Ownership Resolution (Phase 2E) for the decision that followed. Re-confirmed the pre-existing path-traversal finding is unrelated to tenant isolation (doesn't bypass any ownership check) and left it for its own dedicated PR. - Filesystem path traversal & storage boundary hardening — the
dedicated follow-up PR Phase 2D flagged; see
Filesystem Path Safety. Centralized
allowlist validation for every task/model/version/alias/run/metric
identifier (
path_safety.py, applied once inpackage/layout.py), defense-in-depth containment checks, a closedartifacts_dircross-org-ingestion vector (unrelated to but discovered alongside the originally-flagged issue), and a fixed/v1/metricsfallback that previously trusted attacker-influenceablemodel_meta.jsonfields for its own path construction. Symlink-aware, not symlink-hostile. Independent of, and layered underneath, Phase 2A–2D's organization enforcement — not a tenant-isolation change. - HIPAA hardening Phase 2E — the legacy-ownership-resolution product
decision Phase 2D deferred; see
Legacy Ownership Resolution.
Went with a dedicated
model.resolve_ownershipIAM permission (checked completely independently ofmodel.use) over theorg_role/"org_admin"alternative, keeping this repo's permission-only (never role-based) authorization policy intact. Self-scoped only — no client-supplied target organization anywhere in the HTTP path. Write-once, idempotent for the resolver's own org, race-safe via a dedicated marker file (not a second ownership source of truth), audited on both success and failure. Usable inAUTH_ENABLED=truedeployments: the permission is registered in omnibioai-auth's permission catalog and granted to that repo'sorg_adminrole (omnibioai-auth PR #57) — see the Phase 2E section for the full history.
- S3 / Azure Blob storage backends
- Step-history sparklines in UI pulled from DB (currently single-point)
- Model signature validation (input/output schema enforcement)
- Resource-scoped
model.use;platform/public model namespace; model deletion remain separately unscoped.
- Parallel coordinates plot for hyperparameter search
- Auto-link
run_id→ model version in UI (Registered As chip) - Pagination + filtering on
GET /v1/modelsandGET /v1/runs/list - Promotion policies (metric threshold gates)
- Regulatory-ready audit and lineage export (PDF/CSV)
- Enterprise biomedical AI governance platform
- Deeper LIMS integration (sample → dataset → run → model chain)