Security Posture
Jammi is an engine on a trusted network, not a security boundary. This page is the published threat model: precisely what the engine defends, what it explicitly does not, the trusted-network assumption every deployment inherits, and the consumer’s responsibilities for the boundary the engine deliberately does not own. Every “defends” line below traces to a real test or code path; every “does not” line is the honest absence of a guarantee the engine never claims.
The single principle, stated once and not softened: Jammi authenticates nothing. Identity, authorization, and the network perimeter are a consumer’s vocabulary; the engine ships the seam a consumer plugs them into, never the policy. A deployment that exposes the engine’s port to an untrusted caller has removed the boundary the engine assumes is there.
What the engine defends
Each line names the mechanism and the test or code path that proves it.
| Defence | Mechanism | Traces to |
|---|---|---|
| Format-version reject-newer | A persisted artifact stamped with a version newer than this build knows is a typed rejection, never a silent misparse into wrong data | manifest.rs (UnsupportedManifestVersion, the read-path guard) + test newer_manifest_version_is_rejected; sidecar.rs (IncompatibleFormat) + test newer_rowmap_version_is_rejected |
| Tenant-scope filtering on every catalog query | The read-side analyzer injects tenant_id = $current OR tenant_id IS NULL on every scan; every register_* and the mutable-table sink calls assert_tenant_matches before INSERT; the backend SQL layer also carries the predicate. A Jammi-owned result table carries no tenant_id column (it is wholly owned by one tenant, or GLOBAL), so a tenant-gating result-table schema provider gates resolution on the catalog owner instead — over every lane (Flight db.sql, gRPC sql, search) a correctly-bound tenant resolves only its own and GLOBAL result tables; a peer’s private table resolves not-found | tenant_scope.rs (TenantScopeAnalyzerRule) + store::result_schema (ResultTableSchemaProvider) + the catalog repos’ assert_tenant_matches; proven across the verb surface by tenant_isolation_oracle.rs::every_case_isolation_holds (every wire rpc covered, asserted by every_rpc_is_covered) |
| Typed error surfaces | Failures are typed variants with stable wire status, not opaque strings: a wrong on-disk shape is JammiError::Schema, a stale tenant write is BackendError::TenantMismatch, an incompatible format is JammiError::IncompatibleFormat | the typed-error definitions per crate; the Format Stability reject paths; the tenant write-guard |
| The BYO-auth resolver seam (covers every transport) | Tenant binding is uniformly resolver-driven: a consumer composing the engine via assemble_grpc_chain supplies its own TenantResolver (async: request metadata → TenantScope), which the single async tenant-binding tower layer applies to every engine gRPC verb AND the Flight SQL db.sql lane (TenantBoundProvider drives the same resolver). One authenticating resolver, plugged in once, authenticates both transports — closing the cross-transport gap where the gRPC plane was authenticated but Flight bound from the unauthenticated jammi-session-id header (#220, now closed). The seam is the same one downstreams compose with; the BYO-auth seam and the composability seam are one seam. The engine still authenticates nothing on its own — the default resolver (SessionIdTenantResolver) binds the tenant a caller asserts via jammi-session-id; supplying an authenticating resolver is the consumer’s job | the seam proof composability_seam.rs::resolver_seam_scopes_both_transports_and_rejects_missing_credential (gRPC + Flight isolation and UNAUTHENTICATED-on-missing across both transports) and the mirror grpc_byo_auth.rs::resolver_seam_binds_the_engine_and_rejects_missing_credential; the lower-level custom-Interceptor-in-front pattern (for fronting your own single service) is pinned by the four guarantees below |
The AdminAuthorizer capability gate — gRPC-only, one grant, one transport | Unlike the resolver seam above (one grant that covers both transports), CatalogService.Reconcile’s cross-tenant all = true admin pass is a SEPARATE, narrower gate: a synchronous AdminAuthorizer (fn authorize(&self, metadata: &MetadataMap) -> Result<(), Status>) a deployment supplies at GrpcChain.admin_authorizer. It is gRPC-only by construction, not by omission — Reconcile has no Flight SQL analogue to gate. The shipped default is admin_authorizer: None, which refuses EVERY all = true request with PERMISSION_DENIED naming this page; a tenant-scoped Reconcile (all = false) never consults it at all and runs under the caller’s own resolved tenant scope like any other verb | grpc/catalog.rs (AdminAuthorizer, CatalogServer::admin_authorizer); default-deny proven by grpc_remote_session.rs::remote_reconcile_all_is_denied_by_default_without_an_authorizer; an authorized pass by grpc_remote_session.rs::remote_reconcile_reports_like_local and the cross-tenant isolation oracle tenant_isolation_oracle.rs::assert_reconcile_isolated, both via the test-only AllowAllAdmin (tests/it/common/grpc.rs); a worked example implementation is in Scope a Session to a Tenant → Bring your own auth |
The BYO-auth seam’s contract is pinned by grpc_byo_auth.rs as a worked
example. The resolver-seam tests above prove it through the engine’s own
composability seam across both transports; the custom-interceptor-in-front form
(a consumer fronting its own single service) additionally pins four guarantees,
each its own test:
- Missing credential → unauthenticated. No token fails the request before any
handler runs, so the caller reads nothing — it does not fall through to an
unscoped read (
missing_credential_is_rejected_not_run_unscoped). - Forged claim → unauthenticated. A token whose signature does not cover its
tenant claim is rejected; a forged tenant buys nothing because the signature
covers the claim (
invalid_credential_is_rejected). - A rejected caller does not fall through. The interceptor fails the
request rather than binding
None— there is no path by which an unauthenticated caller silently reads another tenant’s rows (the same test proves a valid token for the very tenant the forgery claimed does resolve, so the rejection was the signature, not a tenant blocklist). - Per-tenant isolation through the seam. Two callers presenting valid tokens
for two distinct tenants each see only their own tenant’s sources, end to end
through the authenticating interceptor
(
two_authenticated_tenants_see_isolated_sources).
What the engine explicitly does NOT defend
These are honest absences. The engine never claims them; a deployment that needs them supplies them above the engine.
- It authenticates nothing. There is no built-in credential check on any
verb. The default
SessionIdTenantResolverreads thejammi-session-idheader and binds the tenant the caller asserts (or the explicitGlobal/unscoped scope when none is bound) — it verifies nothing about who the caller is. A deployment that needs authentication supplies its ownTenantResolverat the seam. - It ships no authz / RBAC / SSO. There is no role model, no permission check, no policy engine, no identity-provider integration. Authorization is a consumer’s vocabulary and lives above the seam.
jammi-session-idis a correlation id, NOT a credential or principal. It is a client-minted, opaque transport correlation id identifying a connection, not a person. Anyone who presents another session’s id assumes that session’s tenant. It is never an authentication or authorization boundary.- No TLS / secrets / IAM. Transport encryption, secret management, key rotation, and cloud IAM are the consumer’s runtime, not the engine’s — the same line the Design Philosophy draws around load balancing, ingress, and orchestration.
- The peer listener authenticates nothing either (I-PEER).
[server] peer_bind(unset by default) serves the engine-internal segment-search seam to other replicas and trusts the channel — see The peer listener below. - The gang listener authenticates nothing either (I-GANG). The same
[server] peer_bindlistener also serves the coordinator-to-member gang admission seam for a multi-host training run and trusts the channel — see The gang listener below. - The Ballista listeners authenticate nothing either (I-PEER).
[ballista] scheduler_bindand[ballista.executor] bind/grpc_bind(all unset by default) open the compute plane’s scheduler gRPC, executor task gRPC and Arrow Flight shuffle ports and trust the channel — see The Ballista listeners below.
The peer listener (I-PEER)
[server] peer_bind opens a separate internal listener serving
jammi.v1.peer.PeerService — the seam a coordinator replica fans a search
out through to the replicas that own a table’s segments (see Beyond one
node). Its threat model
is stated as one invariant, I-PEER:
- Every client of
peer_bindis a jammi coordinator. The owner handler trusts the channel: the request carries no tenant, the owner binds none and reads noresult_tablesrow. It enforces exactly one thing at its input edge — every requested segment id belongs to the named table (else the whole request is refused) and the bundle’s stamped precision matches. - Tenant scope is enforced once, at the coordinator. The coordinator’s
Searchresolved the table through its own tenant-scoped catalog read (tenant_id = $current OR tenant_id IS NULL) before any fan-out, so a coordinator bound to tenant B cannot name tenant A’s table — it fails before a single peer call. The owner is the second half of that one predicate, not a second predicate. - The public listener never reaches it. The peer routes are built outside
assemble_grpc_chain, never wrapped by the tenant-binding layer, never advertised byGetServerInfo; the public listener answersUNIMPLEMENTEDfor/jammi.v1.peer.PeerService/*(proven by the tenant-isolation oracle). - Binding
peer_bindon a routable interface without network policy / mTLS exposes cross-tenant segment reads to anyone who can reach the port. The listener speaks plaintext gRPC like every other engine port; encryption and peer authentication are the runtime’s (a mesh, a network policy, mTLS at a sidecar), exactly as for the public listener. Default unset = no listener.
The gang listener (I-GANG)
The same [server] peer_bind listener also serves jammi.v1.gang.GangService
(RunRank) — the seam a coordinator uses to admit a member into a multi-host
training run. Its threat model is stated as one invariant, I-GANG:
- Every client of
peer_bindis a jammi coordinator, the same trust I-PEER states — no separate authentication for the gang seam. - Tenant is derived from the job row, never accepted from the caller. A
RunRankcall carries only job coordinates (job_id,attempt,rank,world,coordinator_instance_id); caller-supplied tenant metadata is never read, and ambient admin scope is refused before any row is read (and again at the training-set resolution site). The admission row the member reads (Catalog::get_job_for_rank, primary key only) carries the row’s OWNtenant_id, and a multi-host job’s training set is resolved under that tenant alone through the strict tenant-pinned resolver (Catalog::get_result_table_for_tenant) — another tenant’s table of the same name, or a global (NULL-tenant) one, never resolves. The job’s coordinates are the capability: a coordinator onpeer_bindholding them is admitted for that job — bounded by I-PEER’s own trust statement above — and can read nothing beyond what that job’s row names. - Non-disclosure on refusal; reasons only after admission. Every admission
determinant — job not found, not running, wrong claimant, wrong attempt,
lease not live or undecodable, world size undecodable or not the caller’s,
the training-set pair missing, the tenant undecodable, the training set
unresolved under the job’s tenant / not ready / its sidecar absent / its
digest mismatching / this host’s store faulting, coordinator not fresh —
collapses to the SAME
status (
FAILED_PRECONDITION) with a fixed message, as the call’s own result: the listener discloses neither a job’s existence, its claimant, its attempt, its tenant, nor another tenant’s table; tests distinguish determinants through a test-only seam, never response text. Only an ADMITTED session — the caller already holds the job’s own coordinates — ends with a named reason in the stream (Aborted{Drain | Refuted | Cancelled | NoBody | StoreUnavailable | Unavailable}). - Admit-and-hold. An admitted rank holds this host’s single job slot (a
peer never claims while it holds a rank, never admits a rank while it runs
a job; a busy slot refuses
UNAVAILABLE, transient) and is re-verified every heartbeat against the same determinants; a DRAIN or RELEASE of the host ends every held rank at once. Aworld_size > 1session runs its rank body (trained over the session’s own stream as a rank of the gang) and ends with that body’s oneOutcome; aworld_size == 1session has no body to run, parks, and endsNoBodyafter one lease window. The member writes nothing to the job row and nothing into the job’s artifact prefix on behalf of a rank: only the lease holder writes. - Multi-host gang admission is a Postgres-only deployment shape — a
SQLite deployment is a single embedded process; there is no second host to
admit at all, so this seam is never exercised there. The admission read’s
lease/freshness facts (
lease_expires_at,instances.last_seen_at) are decoded CLIENT-SIDE from the row’s raw stored text (jammi_db::catalog::lease::{decode_lease_expires_at, last_seen_at_is_fresh}), never a SQL-side cast, so a malformed value is a row fact the handler refuses on EITHER backend, never a read fault on one and a live-row fact on the other (https://github.com/f-inverse/jammi-ai/issues/574); the CLAIM and RECLAIM predicates (Catalog::claim_next,Catalog::reclaim_expired_jobs) are the ones that still require a shared, single-writer clock (Postgres’snow()) — those WRITE, and a wrongly-reaped live claimant is destructive in a way a stale admission read (self-correcting at the next heartbeat) is not. - The public listener never reaches it. The gang routes are mounted beside
PeerServiceoutsideassemble_grpc_chain, never wrapped by the tenant-binding layer, never advertised byGetServerInfo; the public listener answersUNIMPLEMENTEDfor/jammi.v1.gang.GangService/*(proven by the tenant-isolation oracle). - Binding
peer_bindon a routable interface without network policy / mTLS exposes the same risk I-PEER states — the listener speaks plaintext gRPC; encryption and peer authentication are the runtime’s. Default unset = no listener = no gang admission surface. - The membership predicate
list_gang_membersnarrows to is a catalog-local read, not an RPC. A row is a member of a listing iff: it is NOT the caller itself; itsworkersrow (an INNER join — a member is a fleet worker with a claim-loop slot, not merely a live process) hasstate = 'claiming'(warming/drainingexcluded);workers.kindscontains the requested kind as a whole, comma-split, trimmed token (never a substring —fine_tunenever matchesgraph_fine_tune);instances.peer_addris non-NULL; andlast_seen_atis fresh underinstance_liveness_margin(lease)(2 × lease) on the DB clock; and itsinstances.result_root_identityEQUALS the caller’s own (unit U5b-1a-A2).peer_addr_ofis the by-id analogue, with no kind/self/root filter (any other member may resolve any other by id). The root is compared by identity, never by spelling:instances.result_rootcarriesresolved_result_root()VERBATIM (sogcs://b/pandgs://b/premain two different STRINGS in that column), andresult_root_identitycarries that root’s identity, derived byMemberRoot::resolvedfrom the same config the store roots itself from: the store’s own URL parser folds the scheme aliases (gcs://→gs://,abfss://→azure://), the bucket is kept as spelled (the driver dials it as spelled) and the key goes through the store’s own key parser (one leading/stripped, a trailing one dropped, an empty segment refused — keys keep their case), the location determinants are read back from the very builder the store constructs for that root (storage::location_determinants: the process environment first,[storage.cloud]on top — for S3/R2 the bucket endpoint the driver dials (endpoint spelling, virtual-hosted or path style, S3 Express, region), the Azure account, endpoint, Azurite host in emulator mode, Fabric switch, the GCS base URL; the identity spells exactly the variables the driver spells and reads their values as the driver reads them, so nothing the driver honours can be missed), a local root is created (as the store creates it at open) and canonicalised on the owning host’s filesystem (symlinks,./.., the filesystem’s own spelling), and an in-memory root is refused at registration as unshareable. Two members whose spellings name one location ARE gang members of each other; two rooted at different locations are not; a row with a NULL identity (pre-036, or a process with no membership) never matches. Root identity equality is NECESSARY, never SUFFICIENT, for shared storage: two identical local roots on two unshared filesystems are indistinguishable to this predicate — sufficiency is the attestation VERIFY’s (U5a-1’s admission-time sidecar, U5b-0’s leaf inventory), not this listing’s. - B5 (this listing is deliberately tenant-free).
instances/workersrows are deployment infrastructure (which processes exist, what they claim, where they are reachable), never tenant data — there is no tenant column on either table, so there is no tenant predicate to drop or keep, andlist_gang_members/peer_addr_ofreturn the identical answer under any tenant scope and under none. Neither verb is reachable from any RPC at all today (no gang/peer handler calls either) — a vacuous truth, not yet a tested boundary; the first RPC that calls one of them owes the enumerating unreachability-or-scoping oracle this listener’s own precedent (GANG_LISTENER_ALLOWLIST/PEER_LISTENER_ALLOWLIST) already sets.
The Ballista listeners (I-PEER)
A process with a [ballista] role opens up to three more internal
listeners, none of them on the public tenant layer: the scheduler’s gRPC
([ballista] scheduler_bind, Ballista’s SchedulerGrpc), the executor’s task
gRPC ([ballista.executor] grpc_bind, ExecutorGrpc) and the executor’s
Arrow Flight shuffle port ([ballista.executor] bind). They serve Ballista’s
own wire package, not jammi.v1, and their threat model is the peer
listener’s, I-PEER:
- Every client of these ports is a jammi role. A scheduler is dialled by the executors it places on and by the scheduler-role process’s own submitter; an executor is dialled by its scheduler and by sibling executors for shuffle reads. The listeners carry no tenant of their own and bind none.
- Tenant scope is enforced once, at the submitting session. The plan a
scheduler places carries the submitter’s tenant inside the operator
descriptor (
JammiCodecwrites it; a plan the codec did not encode is refused by magic). An executor rebuilding a result-table read resolves the table through the strict tenant-pinned catalog read under exactly that tenant — never the executor process’s ambient scope, which a scheduler or executor process does not have. A descriptor naming another tenant’s table is refused with the same non-disclosing not-found the public layer answers. - The public listener never serves them. No Ballista service is mounted
on
assemble_grpc_chain’s routes; the tenant-isolation oracle proves the public listener answersUNIMPLEMENTEDforSchedulerGrpc, the same way it does forPeerServiceandGangService. - Binding them on a routable interface without network policy / mTLS
exposes the compute plane — task submission, shuffle reads, executor
registration — to anyone who can reach the ports, exactly as for
peer_bind. The shape-d overlay binds them on the pod network and the deployer owes the network policy that keeps them cluster-internal; encryption and peer authentication are the runtime’s (a mesh, mTLS at a sidecar). Default unset = no listener.
Transport encryption is the deployer’s runtime, not the engine’s
The engine speaks plaintext gRPC and Flight SQL and ships no TLS code path; transport encryption is the deployer’s runtime. It follows from the same primitives this page and the Design Philosophy already state:
- B4 (“one binary, every topology”) constrains what the engine forks
on, not what fronts it. Terminating TLS is supplied by the runtime the
engine deploys into — a proxy, a mesh sidecar, a load balancer — and that
termination is not a topology-specific code path the engine would need to
special-case per shape. A
tlscargo feature would itself be the kind of server-only gate B4 refuses: a build-time fork between “the engine” and “the engine, but for a server.” - Passing the discipline test is necessary, not sufficient. A user who
has never heard of any consumer does want the wire encrypted — TLS passes
the discipline test on its own. But
the boundary table and the paragraph that follows it
(
docs/guide/src/philosophy.md:116-125) are the second gate: TLS, secrets, IAM, ingress, and load balancing all pass the discipline test and are still placed in the consumer’s runtime, not the engine, because the table asks a second question the discipline test does not — does owning this turn the engine into infrastructure it isn’t. TLS termination answers yes. - A
[server] tlskey, in the file or the environment, is a typed refusal, not a silent no-op.ServerConfig— what[server]deserializes into — is a#[serde(default, deny_unknown_fields)]struct (crates/jammi-db/src/config/mod.rs:1123), the same disciplineJammiConfigitself carries at its top level (crates/jammi-db/src/config/mod.rs:204). A[server] tls = …stanza in a config file, andJAMMI_SERVER__TLSin the environment, are not silently ignored — each is a typedJammiError::Configstartup refusal, naming the unrecognised key. - There is no engine-side certificate to hand the
TenantResolverseam. Because termination happens outside the engine, the engine never sees a peer certificate to map onto a tenant — that mapping, if a deployment wants one, lives in the terminator or the proxy in front, not at the seam described under The identity seam. The CLI’s--targetrefusesgrpcs://andhttps://with a typed error naming the accepted schemes (crates/jammi-cli/src/main.rs:170-187; the CHANGELOG’s “dropgrpcs://andhttps://as accepted--targetschemes” entry (#480), commit616bb6d4) rather than advertising a transport it cannot speak — put a TLS-terminating proxy in front and point--targetat it in plaintext (grpc:///http://). This is an asymmetry between the two clients: the Python SDK’sRemoteTargetlegitimately keepsgrpcs:///https://in its own scheme table (clients/python/jammi/_target.py:51-54) because it is a general client library reaching whatever endpoint a deployment publishes (including a TLS-terminating proxy), while the CLI is the engine’s own admin surface and names only the schemes the engine itself speaks.
Shape B with no mesh — an on-prem single-tenant deployment that has no ingress or mesh to terminate TLS for it — still gets encryption: put a terminator in front of the engine’s plaintext listeners. A minimal, consumer-neutral example with Caddy:
# Caddyfile — terminates TLS and forwards plaintext to the engine's
# loopback-bound listeners (see docker-compose.yml).
#
# `tls internal` issues Caddy's own locally-trusted certificate: a private
# DNS name (no public record) has no ACME challenge path to a public CA,
# so automatic Let's Encrypt/ZeroSSL issuance is not an option here.
jammi.example.com {
tls internal
reverse_proxy h2c://127.0.0.1:8081 # gRPC + Flight SQL
}
health.jammi.example.com {
tls internal
reverse_proxy 127.0.0.1:8080 # /healthz, /readyz, /metrics
}
The reference deploy/docker-compose.yml
binds its published ports to 127.0.0.1 for exactly this shape: the
compose stack publishes the engine’s ports for a terminator running on the
same host to reach, not for direct exposure to an untrusted network. A
terminator running as a container on the same Compose network instead
reaches the engine by its service name rather than 127.0.0.1 —
reverse_proxy h2c://jammi-server:8081 — since two containers on the same
Compose network share that network, not the host’s loopback interface.
The trusted-network assumption
Every Jammi deployment that uses the default SessionIdTenantResolver assumes a
trusted network: a private VPC, a sidecar mesh, or a single-process embedding
where every caller is already inside the trust boundary. On that network, binding
the tenant a caller asserts via jammi-session-id is the right, low-friction
trade-off. The moment an untrusted caller can reach the port, that trade-off is
wrong — and closing it is the consumer’s job, via an authenticating
TenantResolver at the seam, not a flag the engine flips.
Tenant scope is an organizational mechanism, not an access-control boundary
This distinction is load-bearing. Tenant-scope filtering (above) is an
organizational mechanism: it keeps one tenant’s catalog rows from appearing
in another tenant’s correctly-bound reads, so a multi-tenant deployment stays
tidy and a buggy caller that writes the wrong tenant_id is refused by
assert_tenant_matches. It is not an access-control boundary: it does not
decide which tenant a caller is entitled to act as. Nothing in the engine
prevents an unauthenticated caller from asserting any tenant it likes via
jammi-session-id and reading that tenant’s rows. Access control — proving a
caller may act as the tenant it claims — is exactly what the BYO-auth seam adds
in front of the scope mechanism. Treating tenant scope as if it were
authorization is the misuse this page exists to forestall.
The consumer’s responsibilities
To put a real tenant boundary in front of untrusted callers, a consumer supplies
the authentication and authorization the engine deliberately omits by
implementing a TenantResolver and passing it to assemble_grpc_chain — one
plug that binds every engine gRPC verb and the Flight db.sql lane through the
single tenant-binding mechanism (the tenant
recipe and the worked example in
grpc_byo_auth.rs):
- Authenticate the principal. In
resolve, read and verify the caller’s credential (a bearer token, an exchanged session cookie, a service-to-service token). A missing or invalid credential returnsErr(Status::unauthenticated)here, before any handler runs. - Authorize the tenant from the verified claim. Derive the tenant from the
verified claim — never from a header the caller controls. This is where the
consumer’s policy lives: which tenant this principal may act as. Return
Ok(TenantScope::Tenant(t)). - The engine binds it. The async tenant-binding layer maps the resolved
scope onto the
SessionTenantrequest extension every verb handler resolves, andTenantBoundProviderbinds it for Flight — the consumer writes onlyresolve.
Because resolve runs in front of every handler, the tenant the engine acts on
is the one the credential proves, not one the caller asserts. Reject, don’t
default: an authenticating resolver returns Tenant/Err and NEVER
TenantScope::Global — returning Global (or, in the lower-level
interceptor-in-front form, binding None) on a failed check runs the request
unscoped, which for a tenant_id IS NULL-bearing catalog is a global read, so a
rejected caller must fail the request. TenantScope::Global is the explicit
unscoped choice the default (OSS-cooperative) resolver returns when no tenant is
bound — never a value a rejection falls through to. That framing rule is the
defect grpc_byo_auth.rs’s missing_credential_is_rejected_not_run_unscoped and
the seam mirror resolver_seam_binds_the_engine_and_rejects_missing_credential
guard against.
Dependency-advisory posture
The engine’s dependency tree is gated in CI by cargo deny against the RustSec
advisory database, plus a license allowlist and the source/ban guards that
formalize the engine’s one-way dependency direction (no proprietary or
non-crates.io crate in the OSS closure). The advisory lane runs the live RustSec
DB on every PR; a documented exception in deny.toml records any advisory the
release knowingly carries, with a written rationale, rather than destabilizing
the freeze with a risky bump. The config is deny.toml at the repo root.