mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-09 14:52:25 +00:00
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:
co-authored by
Claude Opus 5
parent
7b29fb2f51
commit
1f8a790004
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user