From 1f8a7900040a023cdc98a5d372e0d4a665fefb1b Mon Sep 17 00:00:00 2001 From: Jonathan M Hsieh Date: Tue, 8 Sep 2026 23:38:24 +0000 Subject: [PATCH] feat(secrets): address Secrets by namespace path A Secret is identified by a namespace path plus a name, and resolution is exact: a Secret under `["prod"]` is not visible from `["prod", "vision"]` and never falls back to a parent. - Every verb takes `namespace_path` keyword-only, defaulting to the root, and so does `EnvVarSecret`. Keyword-only from the start, so a later parameter cannot be mistaken for the path. - `EnvVarSecret` pins the path at construction and records the full id -- path plus name, joined with `$`. A worker resolves the id it was handed and never re-resolves against its own default namespace, so the same Function resolves the same Secret wherever it runs. - A root path is omitted from the request body rather than sent empty, so a root request is byte identical to one from a client that predates this. That is what lets the parameter ship before every server implements it -- a server that does not is asked nothing new. - Segments follow the Secret name rule, and necessarily so: the join has to read the same from either side, so neither may contain the delimiter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XE1UwYKsgbb3USBfkqCE6v --- python/python/lancedb/_lancedb.pyi | 16 ++- python/python/lancedb/db.py | 98 +++++++++++----- python/python/lancedb/functions.py | 4 +- python/python/lancedb/remote/db.py | 28 +++-- python/python/lancedb/secrets.py | 67 ++++++++++- .../tests/test_first_class_function_slice2.py | 37 ++++++ python/src/connection.rs | 38 +++++-- rust/lancedb/src/connection.rs | 42 +++++-- rust/lancedb/src/database.rs | 20 +++- rust/lancedb/src/remote/db.rs | 107 ++++++++++++++---- 10 files changed, 365 insertions(+), 92 deletions(-) diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 3d84c2423..fd7ce72eb 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -153,11 +153,17 @@ class Connection(object): async def get_function(self, name: str, version: str) -> str: ... async def list_functions(self) -> List[str]: ... async def drop_function(self, name: str, version: str) -> bool: ... - async def create_secret(self, name: str, value: str) -> None: ... - async def alter_secret(self, name: str, value: str) -> None: ... - async def list_secrets(self) -> List[str]: ... - async def drop_secret(self, name: str) -> None: ... - async def describe_secret(self, name: str) -> Dict[str, str]: ... + async def create_secret( + self, name: str, value: str, namespace_path: List[str] + ) -> None: ... + async def alter_secret( + self, name: str, value: str, namespace_path: List[str] + ) -> None: ... + async def list_secrets(self, namespace_path: List[str]) -> List[str]: ... + async def drop_secret(self, name: str, namespace_path: List[str]) -> None: ... + async def describe_secret( + self, name: str, namespace_path: List[str] + ) -> Dict[str, str]: ... async def list_jobs(self) -> List[JobInfo]: ... async def cancel_job(self, job_id: str) -> bool: ... async def execute_query_async( diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 804a98080..f2d8ea03c 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -58,7 +58,12 @@ from .materialized_view import ( SelectArg, normalize_select, ) -from .secrets import EnvVarSecret, SecretInfo, validate_secret_name +from .secrets import ( + EnvVarSecret, + SecretInfo, + validate_namespace_path, + validate_secret_name, +) from .table import ( AsyncTable, LanceTable, @@ -779,7 +784,9 @@ class DBConnection(EnforceOverrides): "Function catalog operations are not supported for this connection type" ) - def create_secret(self, name: str, value: str) -> None: + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Create a named Secret in this database. Fails if the name is taken, so a create never silently becomes a @@ -791,7 +798,9 @@ class DBConnection(EnforceOverrides): "Secret operations are not supported for this connection type" ) - def alter_secret(self, name: str, value: str) -> None: + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Replace the credential behind an existing Secret. Fails if it does not exist. Every Function bound to the Secret uses the @@ -803,7 +812,7 @@ class DBConnection(EnforceOverrides): "Secret operations are not supported for this connection type" ) - def list_secrets(self) -> List[str]: + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: """The names of every Secret in this database. Names only. No method returns a stored credential, by construction @@ -813,7 +822,9 @@ class DBConnection(EnforceOverrides): "Secret operations are not supported for this connection type" ) - def drop_secret(self, name: str) -> None: + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Drop a Secret. Functions bound to it fail at their next job, naming the Secret; that @@ -825,7 +836,9 @@ class DBConnection(EnforceOverrides): "Secret operations are not supported for this connection type" ) - def describe_secret(self, name: str) -> SecretInfo: + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: """What this database records about a Secret: name and timestamps. Never the value -- there is no code path that could return one. Local @@ -1569,24 +1582,32 @@ class LanceDBConnection(DBConnection): return LOOP.run(self._conn.drop_function(name, version=version)) @override - def create_secret(self, name: str, value: str) -> None: - LOOP.run(self._conn.create_secret(name, value)) + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path)) @override - def alter_secret(self, name: str, value: str) -> None: - LOOP.run(self._conn.alter_secret(name, value)) + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path)) @override - def list_secrets(self) -> List[str]: - return LOOP.run(self._conn.list_secrets()) + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: + return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path)) @override - def drop_secret(self, name: str) -> None: - LOOP.run(self._conn.drop_secret(name)) + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path)) @override - def describe_secret(self, name: str) -> SecretInfo: - return LOOP.run(self._conn.describe_secret(name)) + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path)) @override def list_jobs(self) -> List[JobInfo]: @@ -2418,34 +2439,59 @@ class AsyncConnection(object): """Drop one exact immutable Function version from the remote catalog.""" return await self._inner.drop_function(name, version) - async def create_secret(self, name: str, value: str) -> None: + async def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Create a named Secret in this database. Fails if the name is taken, so a create never silently becomes a rotation. Nothing reads the value back. """ - await self._inner.create_secret(validate_secret_name(name), value) + await self._inner.create_secret( + validate_secret_name(name), + value, + list(validate_namespace_path(namespace_path)), + ) - async def alter_secret(self, name: str, value: str) -> None: + async def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Replace the credential behind an existing Secret. Fails if it does not exist. Bound Functions use the new value from their next job, with no new Function version. """ - await self._inner.alter_secret(validate_secret_name(name), value) + await self._inner.alter_secret( + validate_secret_name(name), + value, + list(validate_namespace_path(namespace_path)), + ) - async def list_secrets(self) -> List[str]: + async def list_secrets( + self, *, namespace_path: Optional[List[str]] = None + ) -> List[str]: """The names of every Secret in this database. Names only.""" - return await self._inner.list_secrets() + return await self._inner.list_secrets( + list(validate_namespace_path(namespace_path)) + ) - async def drop_secret(self, name: str) -> None: + async def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: """Drop a Secret. Bound Functions fail at their next job.""" - await self._inner.drop_secret(validate_secret_name(name)) + await self._inner.drop_secret( + validate_secret_name(name), list(validate_namespace_path(namespace_path)) + ) - async def describe_secret(self, name: str) -> SecretInfo: + async def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: """What this database records about a Secret. Never the value.""" return SecretInfo.from_json( - await self._inner.describe_secret(validate_secret_name(name)) + await self._inner.describe_secret( + validate_secret_name(name), + list(validate_namespace_path(namespace_path)), + ) ) async def list_jobs(self) -> List[JobInfo]: diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index 77316dff2..2a02d9d19 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -1333,7 +1333,9 @@ class UdfDefinition: ) if not bindings: return self._request - resolved = {binding.env_variable: binding.secret for binding in bindings} + # The binding records the full id -- path plus name -- because that is + # what the service resolves. At the root it is the bare name. + resolved = {binding.env_variable: binding.secret_id for binding in bindings} return self._request._copy(update={"secret_bindings": resolved}) def __call__(self, *args, **kwargs): diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index 83e9aacb1..083f9926d 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -778,24 +778,32 @@ class RemoteDBConnection(DBConnection): return LOOP.run(self._conn.drop_function(name, version=version)) @override - def create_secret(self, name: str, value: str) -> None: - LOOP.run(self._conn.create_secret(name, value)) + def create_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path)) @override - def alter_secret(self, name: str, value: str) -> None: - LOOP.run(self._conn.alter_secret(name, value)) + def alter_secret( + self, name: str, value: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path)) @override - def describe_secret(self, name: str) -> SecretInfo: - return LOOP.run(self._conn.describe_secret(name)) + def describe_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> SecretInfo: + return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path)) @override - def list_secrets(self) -> List[str]: - return LOOP.run(self._conn.list_secrets()) + def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]: + return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path)) @override - def drop_secret(self, name: str) -> None: - LOOP.run(self._conn.drop_secret(name)) + def drop_secret( + self, name: str, *, namespace_path: Optional[List[str]] = None + ) -> None: + LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path)) @override def list_jobs(self) -> List["JobInfo"]: diff --git a/python/python/lancedb/secrets.py b/python/python/lancedb/secrets.py index b16f32314..023b18df5 100644 --- a/python/python/lancedb/secrets.py +++ b/python/python/lancedb/secrets.py @@ -27,6 +27,41 @@ def validate_secret_name(name: str) -> str: return name +def validate_namespace_path(namespace_path=None): + """Check a namespace path locally and return it as a tuple. + + ``None`` and ``[]`` both mean the root namespace. Segments follow the same + rule as Secret names, and necessarily so: an id is the path plus the name + joined with ``$``, so the join has to read the same from either side. + """ + if namespace_path is None: + return () + if isinstance(namespace_path, str): + raise TypeError( + "namespace_path must be a list of segments, not a string; " + f"did you mean [{namespace_path!r}]?" + ) + segments = tuple(namespace_path) + for segment in segments: + if not isinstance(segment, str): + raise TypeError( + f"namespace path segment must be a string, not {type(segment).__name__}" + ) + if not _SECRET_NAME.fullmatch(segment): + raise ValueError(f"invalid namespace path segment: {segment!r}") + return segments + + +def secret_id(secret: str, namespace_path=()) -> str: + """The id a Secret is named by: its namespace path plus its name. + + Joined with ``$``, which is why neither a name nor a segment may contain + one. At the root this is the bare name, so a root id is unchanged from + before namespaces existed. + """ + return "$".join((*namespace_path, secret)) + + def validate_env_variable(name: str) -> str: """Check an environment variable name locally and return it unchanged.""" if not isinstance(name, str): @@ -72,11 +107,12 @@ class EnvVarSecret: ('openai-prod', 'OPENAI_API_KEY') """ - __slots__ = ("_secret", "_env_variable") + __slots__ = ("_secret", "_env_variable", "_namespace_path") - def __init__(self, secret: str, env_variable: str): + def __init__(self, secret: str, env_variable: str, *, namespace_path=None): self._secret = validate_secret_name(secret) self._env_variable = validate_env_variable(env_variable) + self._namespace_path = validate_namespace_path(namespace_path) @property def secret(self) -> str: @@ -88,10 +124,30 @@ class EnvVarSecret: """The environment variable the value is delivered in.""" return self._env_variable + @property + def namespace_path(self): + """The namespace path the Secret is addressed within, root when empty.""" + return list(self._namespace_path) + + @property + def secret_id(self) -> str: + """The full id the binding records: the path plus the name. + + Pinned at construction, so a worker resolves the id it was handed and + never re-resolves against its own default namespace -- the same + Function resolves the same Secret wherever it runs. + """ + return secret_id(self._secret, self._namespace_path) + def __repr__(self) -> str: + path = ( + f", namespace_path={list(self._namespace_path)!r}" + if self._namespace_path + else "" + ) return ( f"EnvVarSecret(secret={self._secret!r}, " - f"env_variable={self._env_variable!r})" + f"env_variable={self._env_variable!r}{path})" ) def __eq__(self, other: object) -> bool: @@ -99,10 +155,13 @@ class EnvVarSecret: isinstance(other, EnvVarSecret) and other._secret == self._secret and other._env_variable == self._env_variable + and other._namespace_path == self._namespace_path ) def __hash__(self) -> int: - return hash((EnvVarSecret, self._secret, self._env_variable)) + return hash( + (EnvVarSecret, self._secret, self._env_variable, self._namespace_path) + ) class SecretInfo: diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 4499769a2..33f40a169 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -92,6 +92,43 @@ def test_secret_bound_udf_matches_its_shared_registration_golden(): ) +def test_a_namespaced_binding_records_the_full_id(): + """A binding records the id the service resolves: path plus name. + + At the root that is the bare name, so a root binding is unchanged from + before namespaces existed -- which is what keeps the wire shape stable. + """ + root = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY") + assert root.secret_id == "openai-prod" + assert root.namespace_path == [] + + nested = EnvVarSecret( + secret="openai-prod", + env_variable="OPENAI_API_KEY", + namespace_path=["prod", "vision"], + ) + assert nested.secret_id == "prod$vision$openai-prod" + assert nested.namespace_path == ["prod", "vision"] + assert nested != root + + bound = analyze_caption.bind_secrets([nested]) + assert bound.secret_bindings == {"OPENAI_API_KEY": "prod$vision$openai-prod"} + + +def test_a_namespace_path_is_validated_locally(): + # The delimiter is why the charset is closed: a segment carrying one would + # make the same id parse two ways. + with pytest.raises(ValueError): + EnvVarSecret( + secret="openai-prod", env_variable="K", namespace_path=["with$delim"] + ) + with pytest.raises(ValueError): + EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path=["a/b"]) + # A bare string is a plausible mistake with the wrong meaning. + with pytest.raises(TypeError): + EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path="prod") + + def test_an_unbound_request_carries_no_binding_at_all(): """Binding is a registration-time decision, so the definition holds none. diff --git a/python/src/connection.rs b/python/src/connection.rs index fdb3a2429..6db7de311 100644 --- a/python/src/connection.rs +++ b/python/src/connection.rs @@ -708,10 +708,14 @@ impl Connection { self_: PyRef<'_, Self>, name: String, value: String, + namespace_path: Vec, ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { - inner.create_secret(name, value).await.infer_error() + inner + .create_secret(name, value, &namespace_path) + .await + .infer_error() }) } @@ -719,33 +723,51 @@ impl Connection { self_: PyRef<'_, Self>, name: String, value: String, + namespace_path: Vec, ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { - inner.alter_secret(name, value).await.infer_error() + inner + .alter_secret(name, value, &namespace_path) + .await + .infer_error() }) } - pub fn list_secrets(self_: PyRef<'_, Self>) -> PyResult> { + pub fn list_secrets( + self_: PyRef<'_, Self>, + namespace_path: Vec, + ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { - inner.list_secrets().await.infer_error() + inner.list_secrets(&namespace_path).await.infer_error() }) } - pub fn drop_secret(self_: PyRef<'_, Self>, name: String) -> PyResult> { + pub fn drop_secret( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Vec, + ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { - inner.drop_secret(name).await.infer_error() + inner.drop_secret(name, &namespace_path).await.infer_error() }) } /// Name and timestamps as a plain mapping. `SecretInfo` carries no value, /// so there is none to filter out here. - pub fn describe_secret(self_: PyRef<'_, Self>, name: String) -> PyResult> { + pub fn describe_secret( + self_: PyRef<'_, Self>, + name: String, + namespace_path: Vec, + ) -> PyResult> { let inner = self_.get_inner()?.clone(); future_into_py(self_.py(), async move { - let info = inner.describe_secret(name).await.infer_error()?; + let info = inner + .describe_secret(name, &namespace_path) + .await + .infer_error()?; Ok(HashMap::from([ ("name".to_string(), info.name), ("created_at".to_string(), info.created_at), diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index b399d9081..c8bb40bb6 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -651,9 +651,14 @@ impl Connection { /// rotation. There is no API that reads a stored credential back; the only /// consumer is a Function that binds the Secret by name. Local databases /// return [`Error::NotSupported`]. - pub async fn create_secret(&self, name: impl AsRef, value: impl AsRef) -> Result<()> { + pub async fn create_secret( + &self, + name: impl AsRef, + value: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { self.internal - .create_secret(name.as_ref(), value.as_ref()) + .create_secret(name.as_ref(), value.as_ref(), namespace_path) .await } @@ -664,9 +669,14 @@ impl Connection { /// minted -- which is what lets a rotation reach columns pinned to a /// version registered before it. Local databases return /// [`Error::NotSupported`]. - pub async fn alter_secret(&self, name: impl AsRef, value: impl AsRef) -> Result<()> { + pub async fn alter_secret( + &self, + name: impl AsRef, + value: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { self.internal - .alter_secret(name.as_ref(), value.as_ref()) + .alter_secret(name.as_ref(), value.as_ref(), namespace_path) .await } @@ -675,8 +685,8 @@ impl Connection { /// Names only. No path in this API returns a stored credential, by /// construction rather than by policy. Local databases return /// [`Error::NotSupported`]. - pub async fn list_secrets(&self) -> Result> { - self.internal.list_secrets().await + pub async fn list_secrets(&self, namespace_path: &[String]) -> Result> { + self.internal.list_secrets(namespace_path).await } /// Drop a Secret. @@ -685,8 +695,14 @@ impl Connection { /// is the revocation path. The name becomes free to reuse, and a new /// Secret under it is picked up by everything still bound to that name. /// Local databases return [`Error::NotSupported`]. - pub async fn drop_secret(&self, name: impl AsRef) -> Result<()> { - self.internal.drop_secret(name.as_ref()).await + pub async fn drop_secret( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result<()> { + self.internal + .drop_secret(name.as_ref(), namespace_path) + .await } /// What this database records about one Secret: its name and timestamps. @@ -694,8 +710,14 @@ impl Connection { /// Never the value. The type it returns has no field for one, so this is a /// property of the API rather than of what the caller chooses to read. /// Local databases return [`Error::NotSupported`]. - pub async fn describe_secret(&self, name: impl AsRef) -> Result { - self.internal.describe_secret(name.as_ref()).await + pub async fn describe_secret( + &self, + name: impl AsRef, + namespace_path: &[String], + ) -> Result { + self.internal + .describe_secret(name.as_ref(), namespace_path) + .await } /// Rename a table in the database. diff --git a/rust/lancedb/src/database.rs b/rust/lancedb/src/database.rs index f4988d217..67b15ab9f 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -339,30 +339,40 @@ pub trait Database: } /// Create a named Secret in this database. Fails if the name is taken, so /// a create can never silently become a rotation. - async fn create_secret(&self, _name: &str, _value: &str) -> Result<()> { + async fn create_secret( + &self, + _name: &str, + _value: &str, + _namespace_path: &[String], + ) -> Result<()> { secret_catalog_not_supported() } /// Replace the credential behind an existing Secret. Fails if it does not /// exist. Every Function bound to it resolves the new value from its next /// execution, with no new Function version. - async fn alter_secret(&self, _name: &str, _value: &str) -> Result<()> { + async fn alter_secret( + &self, + _name: &str, + _value: &str, + _namespace_path: &[String], + ) -> Result<()> { secret_catalog_not_supported() } /// The names of every Secret in this database. /// /// Names only. No API path returns a stored credential, by construction /// rather than by policy. - async fn list_secrets(&self) -> Result> { + async fn list_secrets(&self, _namespace_path: &[String]) -> Result> { secret_catalog_not_supported() } /// Drop a Secret. Functions bound to it fail at their next job, which is /// the revocation path. - async fn drop_secret(&self, _name: &str) -> Result<()> { + async fn drop_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<()> { secret_catalog_not_supported() } /// What the database records about one Secret: its name and timestamps, /// never its value. - async fn describe_secret(&self, _name: &str) -> Result { + async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result { secret_catalog_not_supported() } /// Open a job by id, returning a handle with its record already diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index 3db66c182..8f57a732f 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -277,6 +277,22 @@ pub struct RemoteHostOverrides { pub sql: Option, } +/// Attach a namespace path to a Secret request body. +/// +/// A root path is omitted rather than sent empty, so a root request is byte +/// identical to one from a client that predates namespace addressing. +fn add_namespace_path(body: &mut serde_json::Value, namespace_path: &[String]) { + if namespace_path.is_empty() { + return; + } + body["namespace_path"] = serde_json::Value::Array( + namespace_path + .iter() + .map(|segment| serde_json::Value::String(segment.clone())) + .collect(), + ); +} + impl RemoteDatabase { pub(crate) fn try_new( uri: &str, @@ -356,11 +372,19 @@ impl RemoteDatabase { /// requires, so they share one request shape. The value is a request field /// and never a path segment or query parameter, which keeps it out of /// access logs and proxy traces. - async fn write_secret(&self, route: &str, name: &str, value: &str) -> Result<()> { - let req = self.client.post(route).json(&serde_json::json!({ + async fn write_secret( + &self, + route: &str, + name: &str, + value: &str, + namespace_path: &[String], + ) -> Result<()> { + let mut body = serde_json::json!({ "name": name, "value": value, - })); + }); + add_namespace_path(&mut body, namespace_path); + let req = self.client.post(route).json(&body); let (request_id, response) = self.client.send(req).await?; self.client.check_response(&request_id, response).await?; Ok(()) @@ -700,15 +724,22 @@ impl Database for RemoteDatabase { Ok(response.dropped) } - async fn create_secret(&self, name: &str, value: &str) -> Result<()> { - self.write_secret("/v1/secrets/create", name, value).await + async fn create_secret( + &self, + name: &str, + value: &str, + namespace_path: &[String], + ) -> Result<()> { + self.write_secret("/v1/secrets/create", name, value, namespace_path) + .await } - async fn alter_secret(&self, name: &str, value: &str) -> Result<()> { - self.write_secret("/v1/secrets/alter", name, value).await + async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> { + self.write_secret("/v1/secrets/alter", name, value, namespace_path) + .await } - async fn list_secrets(&self) -> Result> { + async fn list_secrets(&self, namespace_path: &[String]) -> Result> { let mut names = Vec::new(); let mut page_token: Option = None; let mut seen_page_tokens = HashSet::new(); @@ -717,6 +748,7 @@ impl Database for RemoteDatabase { if let Some(token) = &page_token { body["page_token"] = serde_json::Value::String(token.clone()); } + add_namespace_path(&mut body, namespace_path); let req = self.client.post("/v1/secrets/list").json(&body); let (request_id, response) = self.client.send(req).await?; let response = self.client.check_response(&request_id, response).await?; @@ -740,21 +772,19 @@ impl Database for RemoteDatabase { Ok(names) } - async fn drop_secret(&self, name: &str) -> Result<()> { - let req = self - .client - .post("/v1/secrets/drop") - .json(&serde_json::json!({ "name": name })); + async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> { + let mut body = serde_json::json!({ "name": name }); + add_namespace_path(&mut body, namespace_path); + let req = self.client.post("/v1/secrets/drop").json(&body); let (request_id, response) = self.client.send(req).await?; self.client.check_response(&request_id, response).await?; Ok(()) } - async fn describe_secret(&self, name: &str) -> Result { - let req = self - .client - .post("/v1/secrets/describe") - .json(&serde_json::json!({ "name": name })); + async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result { + let mut body = serde_json::json!({ "name": name }); + add_namespace_path(&mut body, namespace_path); + let req = self.client.post("/v1/secrets/describe").json(&body); let (request_id, response) = self.client.send(req).await?; let response = self.client.check_response(&request_id, response).await?; response.json().await.err_to_http(request_id) @@ -2886,11 +2916,11 @@ mod tests { http::Response::builder().status(200).body("{}").unwrap() }); if call { - conn.create_secret("openai-prod", "sk-live-0001") + conn.create_secret("openai-prod", "sk-live-0001", &[]) .await .unwrap(); } else { - conn.alter_secret("openai-prod", "sk-live-0001") + conn.alter_secret("openai-prod", "sk-live-0001", &[]) .await .unwrap(); } @@ -2912,7 +2942,7 @@ mod tests { http::Response::builder().status(200).body(body).unwrap() }); assert_eq!( - conn.list_secrets().await.unwrap(), + conn.list_secrets(&[]).await.unwrap(), vec!["openai-prod".to_string(), "hf-prod".to_string()] ); } @@ -2927,7 +2957,7 @@ mod tests { .body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#) .unwrap() }); - let error = conn.list_secrets().await.unwrap_err(); + let error = conn.list_secrets(&[]).await.unwrap_err(); assert!( error.to_string().contains("repeated a page_token"), "{error}" @@ -2943,7 +2973,38 @@ mod tests { assert_eq!(body, serde_json::json!({"name": "openai-prod"})); http::Response::builder().status(200).body("{}").unwrap() }); - conn.drop_secret("openai-prod").await.unwrap(); + conn.drop_secret("openai-prod", &[]).await.unwrap(); + } + + /// A namespace path is sent when there is one and omitted when there is + /// not, so a root request stays byte identical to one from a client that + /// predates namespace addressing -- which is what lets the parameter ship + /// before every server implements it. + #[tokio::test] + async fn test_a_namespace_path_is_sent_only_when_it_is_not_root() { + let conn = Connection::new_with_handler(|request| { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "name": "openai-prod", + "namespace_path": ["prod", "vision"], + }) + ); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()]) + .await + .unwrap(); + + let conn = Connection::new_with_handler(|request| { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert!(body.get("namespace_path").is_none(), "{body}"); + http::Response::builder().status(200).body("{}").unwrap() + }); + conn.drop_secret("openai-prod", &[]).await.unwrap(); } #[tokio::test]