refactor(secrets): enforce tenant isolation via ScopedFilesystem in FilesystemSecretStore/Broker

Mirrors the engine migration in commit ac8e677f9: `FilesystemSecretStore`
and `FilesystemCredentialBroker` previously held `Arc<F: RootFilesystem>`
and manually encoded tenant/user identity in every path (`secret_owner_root`
formatted `/secrets/tenants/{tenant}/users/{user}/agents/.../projects/...`).
Any composition layer that forgot to wrap the backend in a tenant scope
would leak across tenants with the type system saying nothing — exactly
the HIGH-severity finding addressed in PR #3679 for `ironclaw_engine`.

Migrated the secret/credential filesystem stores so tenant isolation is
structural, not a convention this crate has to remember:

- `FilesystemSecretStore::new` / `with_lease_ttl` and
  `FilesystemCredentialBroker::new` now take `Arc<ScopedFilesystem<F>>`.
- Every path helper (`secret_path`, `lease_path`, `lease_root`,
  `credential_account_path`, `credential_account_root`,
  `credential_session_path`) returns `ScopedPath` instead of `VirtualPath`.
  Paths drop the `/secrets/tenants/<tenant>/users/<user>` prefix and become
  alias-relative under the `/secrets` mount alias; tenant + user are now
  resolved per-op by the caller's `MountView`.
- `secret_owner_alias` retains the conditional agent/project suffix that
  the legacy `secret_owner_root` had, so secrets remain partitioned by
  (agent, project) inside a user's namespace. AAD (`filesystem_secret_aad`)
  already binds the same `(tenant, user, agent, project, handle)` tuple
  — sub-scope binding stays intact, and cross-owner reads still fail
  closed both at the path and at decrypt.
- `put_with_version_fallback` now takes `&ScopedFilesystem<F>` /
  `&ScopedPath`. CAS-retry loops on `consume` / `revoke` /
  `consume_session_use` and the `Unsupported`→`Any` fallback for byte-only
  backends are unchanged in behaviour. The constant-time `subtle` compare
  in `legacy_store` and the revoke/consume CAS race fixes from
  `df8ae8d1c`/`f04a99813`/`9c079dd76` are untouched.
- `leases_for_scope` and `accounts_for_scope` now reconstruct child
  `ScopedPath`s from `list_dir`'s `VirtualPath` results (via
  `join_scoped_secret` / `join_scoped_broker`) so the per-op ACL still
  runs on the follow-up `get`.
- Tests build a `ScopedFilesystem` with
  `MountPermissions::read_write_list_delete()` on alias `/secrets` →
  tenant-scoped target. The encryption-at-rest assertions resolve the
  alias-relative path through the same `MountView` to read raw backend
  bytes. The CAS-retry `VersionRacingBackend` tests watch the resolved
  `VirtualPath` so the racing semantics are preserved.
- Added regression test
  `filesystem_secret_store_isolates_two_tenants_with_same_user_project_ids`:
  two `FilesystemSecretStore`s share one `InMemoryBackend` but have
  different MountViews. Writing the same `(user_id, project_id, handle)`
  on tenant A must not be visible from tenant B — `metadata` returns
  `None`, `lease_once` returns `UnknownSecret`, and `leases_for_scope`
  is empty.
- Added regression test
  `filesystem_secret_store_aad_validates_cross_invocation_within_same_owner`
  locking in the AAD-alignment fix from commit `199137b57`: AAD binds the
  owner scope only, so two reads under different
  invocation/mission/thread but identical `(tenant, user, agent, project)`
  successfully round-trip through `consume` after the ScopedFilesystem
  migration.
- Replaced the now-meaningless `filesystem_secret_store_isolates_scopes`
  test (which wrote two different-tenant secrets to one store) with
  `filesystem_secret_store_isolates_projects_within_same_mount`: within a
  single store, cross-project consume still fails closed because project
  remains in the path.

Trait surfaces (`SecretStore`, `CredentialAccountStore`,
`CredentialSessionStore`) are unchanged. Composition wiring
(`ironclaw_reborn_composition`) and other consumer crates are not
touched here — to be migrated separately as listed in
`docs/plans/2026-05-16-scoped-filesystem-tenant-isolation.md`.

File size note: `filesystem_store.rs` grows from 1740 to ~2020 lines.
The added bulk is regression tests + crypto-bound migration helpers
that must live alongside the migrated code. The file already exceeds
the 1500-line architecture threshold; tracked under the broader
ScopedFilesystem migration plan above.
This commit is contained in:
ilblackdragon@gmail.com
2026-05-16 17:08:47 -07:00
parent 5e7688d3b5
commit 4ae56769b0

View File

@@ -6,16 +6,27 @@
//! fabric as the rest of Reborn (`ironclaw_processes`, `ironclaw_authorization`,
//! `ironclaw_outbound`, `ironclaw_run_state`).
//!
//! Path layout, all under tenant/user/agent/project/mission/thread-scoped
//! prefixes:
//! All paths are alias-relative [`ScopedPath`] strings under the `/secrets`
//! mount alias. Tenant and user isolation are enforced structurally by the
//! caller-supplied [`MountView`](ironclaw_host_api::MountView): composition
//! wires `/secrets` → `/tenants/<tenant_id>/users/<user_id>/secrets`, so
//! storage code never has to encode or remember tenant/user identity. The
//! agent/project sub-scope remains in the path because secrets are partitioned
//! within a user's namespace by integration/project; the AAD (see
//! [`filesystem_secret_aad`](crate::filesystem_secret_aad)) binds ciphertext
//! to the same `(tenant, user, agent, project, handle)` tuple so cross-owner
//! reads fail closed both at the path layer and via decryption.
//!
//! - `/secrets/<owner-prefix>/secrets/<handle>.json` — encrypted secret material
//! - `/secrets/<owner-prefix>/secret-leases/<lease_id>.json` — active/consumed/
//! revoked/expired lease metadata
//! - `/secrets/<owner-prefix>/credential-accounts/<account_id>.json` — credential
//! account records (encrypted target/extension metadata)
//! - `/secrets/<owner-prefix>/credential-sessions/<session_id>.json` — credential
//! session records (encrypted session payload + use counter)
//! Path layout (alias-relative):
//!
//! - `/secrets/agents/<agent>/projects/<project>/secrets/<handle>.json`
//! - `/secrets/agents/<agent>/projects/<project>/secret-leases/<lease_id>.json`
//! - `/secrets/agents/<agent>/projects/<project>/credential-accounts/<account_id>.json`
//! - `/secrets/agents/<agent>/projects/<project>/credential-sessions/<session_id>.json`
//!
//! `agents/<agent>` and `projects/<project>` segments are omitted when the
//! scope does not carry that field (mirroring the legacy
//! `secret_owner_root` shape so existing AAD bindings remain valid).
//!
//! Encryption-at-rest currently lives **inside this store** rather than as a
//! generic [`EncryptedBackend`] backend decorator. The
@@ -34,10 +45,12 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use ironclaw_filesystem::{CasExpectation, ContentType, Entry, FilesystemError, RootFilesystem};
use ironclaw_filesystem::{
CasExpectation, ContentType, Entry, FilesystemError, RootFilesystem, ScopedFilesystem,
};
use ironclaw_host_api::{
AgentId, HostApiError, MissionId, ProjectId, ResourceScope, SecretHandle, ThreadId, Timestamp,
VirtualPath,
AgentId, HostApiError, MissionId, ProjectId, ResourceScope, ScopedPath, SecretHandle, ThreadId,
Timestamp,
};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
@@ -171,14 +184,19 @@ impl SerializableCredentialSession {
/// Filesystem-backed [`SecretStore`].
///
/// Construct with any [`RootFilesystem`]. Encryption is currently embedded
/// (see the module docstring) and uses the same [`SecretsCrypto`] as the
/// libSQL/Postgres backends.
/// Construct with a [`ScopedFilesystem`] over any [`RootFilesystem`].
/// Encryption is currently embedded (see the module docstring) and uses the
/// same [`SecretsCrypto`] as the libSQL/Postgres backends. Tenant and user
/// isolation are enforced by the caller's
/// [`MountView`](ironclaw_host_api::MountView): the `/secrets` alias resolves
/// to a per-tenant/per-user [`VirtualPath`](ironclaw_host_api::VirtualPath)
/// before any backend dispatch, so two stores wrapping the same backend with
/// different mounts cannot read each other's data.
pub struct FilesystemSecretStore<F>
where
F: RootFilesystem,
{
filesystem: Arc<F>,
filesystem: Arc<ScopedFilesystem<F>>,
crypto: Arc<SecretsCrypto>,
lease_ttl: Duration,
}
@@ -187,7 +205,7 @@ impl<F> FilesystemSecretStore<F>
where
F: RootFilesystem,
{
pub fn new(filesystem: Arc<F>, crypto: Arc<SecretsCrypto>) -> Self {
pub fn new(filesystem: Arc<ScopedFilesystem<F>>, crypto: Arc<SecretsCrypto>) -> Self {
Self {
filesystem,
crypto,
@@ -196,7 +214,7 @@ where
}
pub fn with_lease_ttl(
filesystem: Arc<F>,
filesystem: Arc<ScopedFilesystem<F>>,
crypto: Arc<SecretsCrypto>,
lease_ttl: Duration,
) -> Self {
@@ -558,9 +576,13 @@ where
if !entry.name.ends_with(".json") {
continue;
}
// `list_dir` returned a `VirtualPath`; reconstruct the equivalent
// `ScopedPath` under our prefix so the per-op ACL on the follow-up
// `get` still runs against the caller's MountView.
let scoped_child = join_scoped_secret(&root, &entry.name)?;
let Some(versioned) = self
.filesystem
.get(&entry.path)
.get(&scoped_child)
.await
.map_err(fs_to_secret_store_error)?
else {
@@ -590,7 +612,7 @@ pub struct FilesystemCredentialBroker<F>
where
F: RootFilesystem,
{
filesystem: Arc<F>,
filesystem: Arc<ScopedFilesystem<F>>,
crypto: Arc<SecretsCrypto>,
}
@@ -598,7 +620,7 @@ impl<F> FilesystemCredentialBroker<F>
where
F: RootFilesystem,
{
pub fn new(filesystem: Arc<F>, crypto: Arc<SecretsCrypto>) -> Self {
pub fn new(filesystem: Arc<ScopedFilesystem<F>>, crypto: Arc<SecretsCrypto>) -> Self {
Self { filesystem, crypto }
}
@@ -710,9 +732,13 @@ where
if !entry.name.ends_with(".json") {
continue;
}
// `list_dir` returned a `VirtualPath`; reconstruct the equivalent
// `ScopedPath` under our prefix so the per-op ACL on the follow-up
// `get` still runs against the caller's MountView.
let scoped_child = join_scoped_broker(&root, &entry.name)?;
let Some(versioned) = self
.filesystem
.get(&entry.path)
.get(&scoped_child)
.await
.map_err(fs_to_broker_error)?
else {
@@ -869,76 +895,121 @@ where
}
// -- Paths ------------------------------------------------------------------
//
// All paths returned here are alias-relative [`ScopedPath`] strings under the
// `/secrets` mount alias. Tenant and user identity are NOT encoded in the
// path — the caller's [`MountView`] resolves `/secrets` to a
// tenant/user-scoped [`VirtualPath`](ironclaw_host_api::VirtualPath) before
// any backend dispatch, so two stores sharing one backend but constructed
// with different MountViews cannot collide on identical (agent, project,
// handle) tuples.
//
// The agent/project segments remain in the path because secrets are
// partitioned within a user's namespace by integration/project; AAD
// (`filesystem_secret_aad`) binds the same `(tenant, user, agent, project,
// handle)` tuple so cross-owner reads fail closed both at the path layer and
// at decrypt.
fn secret_path(
scope: &ResourceScope,
handle: &SecretHandle,
) -> Result<VirtualPath, SecretStoreError> {
VirtualPath::new(format!(
) -> Result<ScopedPath, SecretStoreError> {
scoped_path_secret(&format!(
"{}/secrets/{}.json",
secret_owner_root(scope),
secret_owner_alias(scope),
handle.as_str()
))
.map_err(host_api_to_secret_store_error)
}
fn lease_path(
scope: &ResourceScope,
lease_id: SecretLeaseId,
) -> Result<VirtualPath, SecretStoreError> {
VirtualPath::new(format!("{}/{lease_id}.json", lease_root(scope)?.as_str()))
.map_err(host_api_to_secret_store_error)
) -> Result<ScopedPath, SecretStoreError> {
scoped_path_secret(&format!("{}/{lease_id}.json", lease_root(scope)?.as_str()))
}
fn lease_root(scope: &ResourceScope) -> Result<VirtualPath, SecretStoreError> {
VirtualPath::new(format!("{}/secret-leases", secret_owner_root(scope)))
.map_err(host_api_to_secret_store_error)
fn lease_root(scope: &ResourceScope) -> Result<ScopedPath, SecretStoreError> {
scoped_path_secret(&format!("{}/secret-leases", secret_owner_alias(scope)))
}
fn credential_account_path(
scope: &ResourceScope,
account_id: &CredentialAccountId,
) -> Result<VirtualPath, CredentialBrokerError> {
VirtualPath::new(format!(
) -> Result<ScopedPath, CredentialBrokerError> {
scoped_path_broker(&format!(
"{}/{}.json",
credential_account_root(scope)?.as_str(),
account_id.as_str()
))
.map_err(host_api_to_broker_error)
}
fn credential_account_root(scope: &ResourceScope) -> Result<VirtualPath, CredentialBrokerError> {
VirtualPath::new(format!("{}/credential-accounts", secret_owner_root(scope)))
.map_err(host_api_to_broker_error)
fn credential_account_root(scope: &ResourceScope) -> Result<ScopedPath, CredentialBrokerError> {
scoped_path_broker(&format!(
"{}/credential-accounts",
secret_owner_alias(scope)
))
}
fn credential_session_path(
scope: &ResourceScope,
session_id: CredentialSessionId,
) -> Result<VirtualPath, CredentialBrokerError> {
VirtualPath::new(format!(
) -> Result<ScopedPath, CredentialBrokerError> {
scoped_path_broker(&format!(
"{}/credential-sessions/{}.json",
secret_owner_root(scope),
secret_owner_alias(scope),
session_id.to_private_storage_string()
))
.map_err(host_api_to_broker_error)
}
fn secret_owner_root(scope: &ResourceScope) -> String {
let mut base = format!(
"/secrets/tenants/{}/users/{}",
scope.tenant_id.as_str(),
scope.user_id.as_str()
);
/// Build the alias-relative owner prefix for a scope, starting from the
/// `/secrets` mount alias. Tenant and user are intentionally absent — they
/// live in the MountView the caller supplied.
fn secret_owner_alias(scope: &ResourceScope) -> String {
let mut base = String::from("/secrets");
if let Some(agent_id) = &scope.agent_id {
base = format!("{base}/agents/{}", agent_id.as_str());
base.push_str("/agents/");
base.push_str(agent_id.as_str());
}
if let Some(project_id) = &scope.project_id {
base = format!("{base}/projects/{}", project_id.as_str());
base.push_str("/projects/");
base.push_str(project_id.as_str());
}
base
}
fn scoped_path_secret(raw: &str) -> Result<ScopedPath, SecretStoreError> {
ScopedPath::new(raw).map_err(host_api_to_secret_store_error)
}
fn scoped_path_broker(raw: &str) -> Result<ScopedPath, CredentialBrokerError> {
ScopedPath::new(raw).map_err(host_api_to_broker_error)
}
/// Join a leaf segment onto a `ScopedPath` prefix. Mirrors the engine's
/// `join_scoped` helper: `list_dir` returns
/// [`VirtualPath`](ironclaw_host_api::VirtualPath) results (post-resolution),
/// but the follow-up `get` must run through the `ScopedFilesystem` so the
/// per-op ACL is enforced — so callers strip the leaf name and rejoin it
/// onto the original `ScopedPath` prefix.
fn join_scoped_secret(prefix: &ScopedPath, leaf: &str) -> Result<ScopedPath, SecretStoreError> {
scoped_path_secret(&format!(
"{}/{}",
prefix.as_str().trim_end_matches('/'),
leaf
))
}
fn join_scoped_broker(
prefix: &ScopedPath,
leaf: &str,
) -> Result<ScopedPath, CredentialBrokerError> {
scoped_path_broker(&format!(
"{}/{}",
prefix.as_str().trim_end_matches('/'),
leaf
))
}
// -- Scope predicates -------------------------------------------------------
fn same_scope_owner(left: &ResourceScope, right: &ResourceScope) -> bool {
@@ -1005,7 +1076,7 @@ fn filesystem_secret_lock_for_lease(
filesystem_secret_lock(key)
}
fn filesystem_session_lock(path: &VirtualPath) -> FilesystemRecordLock {
fn filesystem_session_lock(path: &ScopedPath) -> FilesystemRecordLock {
filesystem_secret_lock(format!("session|{}", path.as_str()))
}
@@ -1048,13 +1119,13 @@ fn lock_or_recover<T>(mutex: &Mutex<HashMap<String, T>>) -> MutexGuard<'_, HashM
/// caller's CAS retry loop can detect contention; we only intercept the
/// specific `Unsupported` shape.
async fn put_with_version_fallback<F>(
filesystem: &F,
path: &ironclaw_host_api::VirtualPath,
filesystem: &ScopedFilesystem<F>,
path: &ScopedPath,
entry: Entry,
cas: CasExpectation,
) -> Result<(), FilesystemError>
where
F: RootFilesystem + ?Sized,
F: RootFilesystem,
{
match filesystem.put(path, entry.clone(), cas).await {
Ok(_) => Ok(()),
@@ -1201,10 +1272,11 @@ mod tests {
use std::sync::Arc;
use chrono::Utc;
use ironclaw_filesystem::InMemoryBackend;
use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem};
use ironclaw_host_api::{
CapabilityId, ExtensionId, InvocationId, MissionId, NetworkMethod, ProjectId,
ResourceScope, SecretHandle, TenantId, ThreadId, UserId,
AgentId, CapabilityId, ExtensionId, InvocationId, MissionId, MountAlias, MountGrant,
MountPermissions, MountView, NetworkMethod, ProjectId, ResourceScope, SecretHandle,
TenantId, ThreadId, UserId, VirtualPath,
};
use secrecy::ExposeSecret;
use serde_json::json;
@@ -1224,6 +1296,34 @@ mod tests {
)
}
/// Build a `ScopedFilesystem` over `backend` whose `/secrets` alias
/// resolves to a tenant/user-scoped [`VirtualPath`] subtree. Tests pass
/// different `target_root` values to simulate distinct
/// (tenant, user) tuples sharing one underlying backend — exactly the
/// shape composition produces in production.
fn build_scoped_fs<B>(backend: Arc<B>, target_root: &str) -> Arc<ScopedFilesystem<B>>
where
B: RootFilesystem,
{
let mounts = MountView::new(vec![MountGrant::new(
MountAlias::new("/secrets").expect("alias"),
VirtualPath::new(target_root).expect("target"),
MountPermissions::read_write_list_delete(),
)])
.expect("mount view");
Arc::new(ScopedFilesystem::new(backend, mounts))
}
/// Default test mount: `/secrets` → tenant-scoped target. Mirrors the
/// production composition shape (`/secrets` →
/// `/tenants/<tenant>/users/<user>/secrets`).
fn default_scoped_fs<B>(backend: Arc<B>) -> Arc<ScopedFilesystem<B>>
where
B: RootFilesystem,
{
build_scoped_fs(backend, "/secrets/tenants/test/users/test/secrets")
}
fn sample_scope(tenant: &str, user: &str) -> ResourceScope {
ResourceScope {
tenant_id: TenantId::new(tenant).unwrap(),
@@ -1263,7 +1363,8 @@ mod tests {
#[tokio::test]
async fn filesystem_secret_store_round_trips_material() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(Arc::clone(&fs), test_crypto());
let scoped = default_scoped_fs(Arc::clone(&fs));
let store = FilesystemSecretStore::new(scoped, test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
@@ -1289,7 +1390,8 @@ mod tests {
#[tokio::test]
async fn filesystem_secret_store_encrypts_at_rest() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(Arc::clone(&fs), test_crypto());
let scoped = default_scoped_fs(Arc::clone(&fs));
let store = FilesystemSecretStore::new(Arc::clone(&scoped), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
@@ -1302,8 +1404,16 @@ mod tests {
.await
.unwrap();
let path = secret_path(&scope, &handle).unwrap();
let versioned = fs.get(&path).await.unwrap().expect("entry persisted");
// Resolve the alias-relative ScopedPath to its backing VirtualPath
// through the same MountView the store uses, so the at-rest check
// reads exactly the bytes the backend stored.
let scoped_path = secret_path(&scope, &handle).unwrap();
let virtual_path = scoped.mounts().resolve(&scoped_path).unwrap();
let versioned = fs
.get(&virtual_path)
.await
.unwrap()
.expect("entry persisted");
let raw = String::from_utf8_lossy(&versioned.entry.body);
assert!(
!raw.contains("plaintext-sentinel-7e3d"),
@@ -1311,17 +1421,24 @@ mod tests {
);
}
/// Within a single store (one MountView, one tenant/user), distinct
/// project scopes still produce disjoint paths — `secret_owner_alias`
/// encodes the project segment under `/secrets/agents/.../projects/<id>`
/// — so a lease issued under project-A cannot be consumed under
/// project-B even though tenant/user agree.
#[tokio::test]
async fn filesystem_secret_store_isolates_scopes() {
async fn filesystem_secret_store_isolates_projects_within_same_mount() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(fs, test_crypto());
let tenant_a = sample_scope("tenant-a", "user-a");
let tenant_b = sample_scope("tenant-b", "user-a");
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
let mut scope_project_a = sample_scope("tenant-a", "user-a");
scope_project_a.project_id = Some(ProjectId::new("project-a").unwrap());
let mut scope_project_b = scope_project_a.clone();
scope_project_b.project_id = Some(ProjectId::new("project-b").unwrap());
let handle = SecretHandle::new("shared_name").unwrap();
store
.put(
tenant_a.clone(),
scope_project_a.clone(),
handle.clone(),
SecretMaterial::from("aaa"),
)
@@ -1329,22 +1446,25 @@ mod tests {
.unwrap();
store
.put(
tenant_b.clone(),
scope_project_b.clone(),
handle.clone(),
SecretMaterial::from("bbb"),
)
.await
.unwrap();
let lease_a = store.lease_once(&tenant_a, &handle).await.unwrap();
let cross = store.consume(&tenant_b, lease_a.id).await.unwrap_err();
let lease_a = store.lease_once(&scope_project_a, &handle).await.unwrap();
let cross = store
.consume(&scope_project_b, lease_a.id)
.await
.unwrap_err();
assert!(cross.is_unknown_lease());
}
#[tokio::test]
async fn filesystem_secret_store_revoke_blocks_consume() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(fs, test_crypto());
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
store
@@ -1371,7 +1491,7 @@ mod tests {
#[tokio::test]
async fn filesystem_secret_store_revoke_after_consume_is_idempotent() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(fs, test_crypto());
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
store
@@ -1404,7 +1524,7 @@ mod tests {
#[tokio::test]
async fn filesystem_secret_store_revoke_is_idempotent_on_revoked() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(fs, test_crypto());
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
store
@@ -1426,7 +1546,7 @@ mod tests {
#[tokio::test]
async fn filesystem_secret_store_missing_secret_does_not_create_lease() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(fs, test_crypto());
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("missing").unwrap();
@@ -1438,7 +1558,7 @@ mod tests {
#[tokio::test]
async fn filesystem_credential_broker_round_trips_account_and_session() {
let fs = Arc::new(InMemoryBackend::new());
let broker = FilesystemCredentialBroker::new(Arc::clone(&fs), test_crypto());
let broker = FilesystemCredentialBroker::new(default_scoped_fs(fs), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let account_id = CredentialAccountId::new("openai_prod").unwrap();
let account = sample_account(
@@ -1501,7 +1621,8 @@ mod tests {
#[tokio::test]
async fn filesystem_credential_broker_account_at_rest_is_encrypted() {
let fs = Arc::new(InMemoryBackend::new());
let broker = FilesystemCredentialBroker::new(Arc::clone(&fs), test_crypto());
let scoped = default_scoped_fs(Arc::clone(&fs));
let broker = FilesystemCredentialBroker::new(Arc::clone(&scoped), test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let account_id = CredentialAccountId::new("github_prod").unwrap();
let mut account = sample_account(
@@ -1512,8 +1633,13 @@ mod tests {
account.label = "leak-sentinel-92ab".to_string();
broker.put_account(account).await.unwrap();
let path = credential_account_path(&scope, &account_id).unwrap();
let versioned = fs.get(&path).await.unwrap().expect("entry persisted");
let scoped_path = credential_account_path(&scope, &account_id).unwrap();
let virtual_path = scoped.mounts().resolve(&scoped_path).unwrap();
let versioned = fs
.get(&virtual_path)
.await
.unwrap()
.expect("entry persisted");
let raw = String::from_utf8_lossy(&versioned.entry.body);
assert!(
!raw.contains("leak-sentinel-92ab"),
@@ -1636,7 +1762,8 @@ mod tests {
// very next `put` (the consume's CAS write) hits a forced
// VersionMismatch.
let inner = StdArc::new(InMemoryBackend::new());
let bootstrap_store = FilesystemSecretStore::new(StdArc::clone(&inner), test_crypto());
let bootstrap_scoped = default_scoped_fs(StdArc::clone(&inner));
let bootstrap_store = FilesystemSecretStore::new(bootstrap_scoped, test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let handle = SecretHandle::new("api_key").unwrap();
bootstrap_store
@@ -1649,12 +1776,18 @@ mod tests {
.unwrap();
let lease = bootstrap_store.lease_once(&scope, &handle).await.unwrap();
let lease_path_for_test = lease_path(&scope, lease.id).unwrap();
let racing = StdArc::new(VersionRacingBackend::new(
StdArc::clone(&inner),
lease_path_for_test,
));
let racing_store = FilesystemSecretStore::new(StdArc::clone(&racing), test_crypto());
// The racing backend watches the post-resolution VirtualPath, so
// resolve the alias-relative ScopedPath through the same MountView
// shape composition uses in production.
let scoped_lease = lease_path(&scope, lease.id).unwrap();
let watched = bootstrap_store
.filesystem
.mounts()
.resolve(&scoped_lease)
.unwrap();
let racing = StdArc::new(VersionRacingBackend::new(StdArc::clone(&inner), watched));
let racing_scoped = default_scoped_fs(StdArc::clone(&racing));
let racing_store = FilesystemSecretStore::new(racing_scoped, test_crypto());
let material = racing_store.consume(&scope, lease.id).await.unwrap();
assert_eq!(material.expose_secret(), "super-secret-cas");
@@ -1671,8 +1804,8 @@ mod tests {
#[tokio::test]
async fn filesystem_broker_consume_session_use_retries_on_version_mismatch() {
let inner = StdArc::new(InMemoryBackend::new());
let bootstrap_broker =
FilesystemCredentialBroker::new(StdArc::clone(&inner), test_crypto());
let bootstrap_scoped = default_scoped_fs(StdArc::clone(&inner));
let bootstrap_broker = FilesystemCredentialBroker::new(bootstrap_scoped, test_crypto());
let scope = sample_scope("tenant-a", "user-a");
let account_id = CredentialAccountId::new("openai_cas").unwrap();
let account = sample_account(
@@ -1702,13 +1835,16 @@ mod tests {
.await
.unwrap();
let correlation = session.correlation_id();
let session_path_for_test = credential_session_path(&scope, correlation).unwrap();
let scoped_session_path = credential_session_path(&scope, correlation).unwrap();
let watched = bootstrap_broker
.filesystem
.mounts()
.resolve(&scoped_session_path)
.unwrap();
let racing = StdArc::new(VersionRacingBackend::new(
StdArc::clone(&inner),
session_path_for_test,
));
let racing_broker = FilesystemCredentialBroker::new(StdArc::clone(&racing), test_crypto());
let racing = StdArc::new(VersionRacingBackend::new(StdArc::clone(&inner), watched));
let racing_scoped = default_scoped_fs(StdArc::clone(&racing));
let racing_broker = FilesystemCredentialBroker::new(racing_scoped, test_crypto());
racing_broker
.consume_session_use(&scope, correlation, Utc::now())
@@ -1737,4 +1873,152 @@ mod tests {
.unwrap_err();
assert!(exceeded.is_use_limit_exceeded());
}
/// Regression for the ScopedFilesystem migration: two stores share one
/// [`InMemoryBackend`] but each is constructed with a [`MountView`]
/// whose `/secrets` alias resolves to a different tenant-scoped
/// [`VirtualPath`] subtree. Writing the same `(user_id, project_id,
/// handle)` tuple on tenant A's store must NOT make the secret visible
/// from tenant B's store. Before this migration, `FilesystemSecretStore`
/// held a raw `Arc<F: RootFilesystem>` and encoded tenant identity in
/// the path itself — any composition layer that forgot to prefix the
/// path with tenant would leak across tenants, with the type system
/// saying nothing. The structural fix routes every op through
/// `ScopedFilesystem`, so two MountViews over the same backend cannot
/// see each other's data.
#[tokio::test]
async fn filesystem_secret_store_isolates_two_tenants_with_same_user_project_ids() {
let backend = Arc::new(InMemoryBackend::new());
let store_a = FilesystemSecretStore::new(
build_scoped_fs(
Arc::clone(&backend),
"/secrets/tenants/a/users/alice/secrets",
),
test_crypto(),
);
let store_b = FilesystemSecretStore::new(
build_scoped_fs(
Arc::clone(&backend),
"/secrets/tenants/b/users/alice/secrets",
),
test_crypto(),
);
// Identical `(user_id, project_id)` for both — the only thing
// keeping the two stores apart is the mount-time tenant prefix.
let scope_a = ResourceScope {
tenant_id: TenantId::new("tenant-a").unwrap(),
user_id: UserId::new("alice").unwrap(),
agent_id: None,
project_id: Some(ProjectId::new("project-1").unwrap()),
mission_id: None,
thread_id: None,
invocation_id: InvocationId::new(),
};
let scope_b = ResourceScope {
tenant_id: TenantId::new("tenant-b").unwrap(),
user_id: UserId::new("alice").unwrap(),
agent_id: None,
project_id: Some(ProjectId::new("project-1").unwrap()),
mission_id: None,
thread_id: None,
invocation_id: InvocationId::new(),
};
let handle = SecretHandle::new("api_key").unwrap();
store_a
.put(
scope_a.clone(),
handle.clone(),
SecretMaterial::from("tenant-a-secret"),
)
.await
.unwrap();
// Tenant A sees its own secret.
assert!(
store_a.metadata(&scope_a, &handle).await.unwrap().is_some(),
"tenant A must see the secret it just wrote"
);
// Tenant B does NOT see tenant A's secret, despite identical
// (user_id, project_id, handle). Both metadata() and lease_once()
// must fail closed.
assert!(
store_b.metadata(&scope_b, &handle).await.unwrap().is_none(),
"tenant B must NOT see tenant A's secret (cross-tenant path leak)"
);
let cross = store_b.lease_once(&scope_b, &handle).await.unwrap_err();
assert!(
cross.is_unknown_secret(),
"tenant B lease_once must fail closed with UnknownSecret; got {cross:?}"
);
// Tenant B's own leases_for_scope is empty under (user, project)
// shared with tenant A.
let b_leases = store_b.leases_for_scope(&scope_b).await.unwrap();
assert!(
b_leases.is_empty(),
"tenant B leases_for_scope must be empty under shared (user, project); got {} leases",
b_leases.len()
);
}
/// Regression for the AAD-alignment bug fixed in `199137b57`: AAD binds
/// ciphertext to the *owner* scope (`tenant/user/agent/project`) only,
/// not the full invocation scope. Two reads issued under different
/// invocation/mission/thread ids but identical owner scope must
/// successfully decrypt the secret the first invocation wrote. Before
/// the AAD-alignment fix this failed with `DecryptionFailed` because
/// AAD bound mission/thread/invocation but the storage path bound only
/// the owner scope. The ScopedFilesystem migration preserves the
/// invariant: tenant/user move into the MountView, but agent/project
/// remain in both the path and the AAD, so cross-invocation reads
/// within one owner still round-trip cleanly.
#[tokio::test]
async fn filesystem_secret_store_aad_validates_cross_invocation_within_same_owner() {
let fs = Arc::new(InMemoryBackend::new());
let store = FilesystemSecretStore::new(default_scoped_fs(fs), test_crypto());
// Same tenant/user/agent/project across both invocations; only the
// invocation/mission/thread fields differ.
let writer_scope = ResourceScope {
tenant_id: TenantId::new("tenant-a").unwrap(),
user_id: UserId::new("user-a").unwrap(),
agent_id: Some(AgentId::new("agent-1").unwrap()),
project_id: Some(ProjectId::new("project-1").unwrap()),
mission_id: Some(MissionId::new("mission-write").unwrap()),
thread_id: Some(ThreadId::new("thread-write").unwrap()),
invocation_id: InvocationId::new(),
};
let mut reader_scope = writer_scope.clone();
reader_scope.mission_id = Some(MissionId::new("mission-read").unwrap());
reader_scope.thread_id = Some(ThreadId::new("thread-read").unwrap());
reader_scope.invocation_id = InvocationId::new();
assert_ne!(
writer_scope.invocation_id, reader_scope.invocation_id,
"test setup error: writer and reader invocations must differ"
);
let handle = SecretHandle::new("api_key").unwrap();
store
.put(
writer_scope.clone(),
handle.clone(),
SecretMaterial::from("cross-invocation-secret"),
)
.await
.unwrap();
// The reader invocation issues its own lease under the same owner
// and must successfully decrypt — AAD binds owner scope only.
let lease = store.lease_once(&reader_scope, &handle).await.unwrap();
let material = store.consume(&reader_scope, lease.id).await.unwrap();
assert_eq!(
material.expose_secret(),
"cross-invocation-secret",
"AAD must bind owner scope only — cross-invocation reads under \
the same (tenant, user, agent, project) must succeed",
);
}
}