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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE1UwYKsgbb3USBfkqCE6v
This commit is contained in:
Jonathan M Hsieh
2026-09-09 03:22:11 +00:00
co-authored by Claude Opus 5
parent 7b29fb2f51
commit 1f8a790004
10 changed files with 365 additions and 92 deletions
+11 -5
View File
@@ -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(
+72 -26
View File
@@ -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]:
+3 -1
View File
@@ -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):
+18 -10
View File
@@ -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"]:
+63 -4
View File
@@ -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:
@@ -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.
+30 -8
View File
@@ -708,10 +708,14 @@ impl Connection {
self_: PyRef<'_, Self>,
name: String,
value: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
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<String>,
) -> PyResult<Bound<'_, PyAny>> {
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<Bound<'_, PyAny>> {
pub fn list_secrets(
self_: PyRef<'_, Self>,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
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<Bound<'_, PyAny>> {
pub fn drop_secret(
self_: PyRef<'_, Self>,
name: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
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<Bound<'_, PyAny>> {
pub fn describe_secret(
self_: PyRef<'_, Self>,
name: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
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),
+32 -10
View File
@@ -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<str>, value: impl AsRef<str>) -> Result<()> {
pub async fn create_secret(
&self,
name: impl AsRef<str>,
value: impl AsRef<str>,
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<str>, value: impl AsRef<str>) -> Result<()> {
pub async fn alter_secret(
&self,
name: impl AsRef<str>,
value: impl AsRef<str>,
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<Vec<String>> {
self.internal.list_secrets().await
pub async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
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<str>) -> Result<()> {
self.internal.drop_secret(name.as_ref()).await
pub async fn drop_secret(
&self,
name: impl AsRef<str>,
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<str>) -> Result<SecretInfo> {
self.internal.describe_secret(name.as_ref()).await
pub async fn describe_secret(
&self,
name: impl AsRef<str>,
namespace_path: &[String],
) -> Result<SecretInfo> {
self.internal
.describe_secret(name.as_ref(), namespace_path)
.await
}
/// Rename a table in the database.
+15 -5
View File
@@ -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<Vec<String>> {
async fn list_secrets(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
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<SecretInfo> {
async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<SecretInfo> {
secret_catalog_not_supported()
}
/// Open a job by id, returning a handle with its record already
+84 -23
View File
@@ -277,6 +277,22 @@ pub struct RemoteHostOverrides {
pub sql: Option<String>,
}
/// 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<S: HttpSend> RemoteDatabase<S> {
/// 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<S: HttpSend> Database for RemoteDatabase<S> {
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<Vec<String>> {
async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
let mut names = Vec::new();
let mut page_token: Option<String> = None;
let mut seen_page_tokens = HashSet::new();
@@ -717,6 +748,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
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<S: HttpSend> Database for RemoteDatabase<S> {
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<SecretInfo> {
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<SecretInfo> {
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]