fix(secrets): composite keyset for group-secret admin list pagination
`list_meta` orders by `(name, environment_scope)` — a group secret name can carry several env-scoped rows — but the cursor encoded only `name` and paginated with `name > $cursor`. When a name's scopes straddled a page boundary, the remaining scope rows were silently skipped from the admin listing. Switch to a composite `(name, environment_scope)` keyset (base64url of `name \x1f scope`) and a Postgres row-comparison predicate. App secrets (single `*` scope per name) were unaffected; group secrets with multi-scope names now page completely. Found in final branch review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,27 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
|
|||||||
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
|
||||||
|
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
|
||||||
|
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
|
||||||
|
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
|
||||||
|
/// secret-name char, so it's an unambiguous delimiter).
|
||||||
|
const CURSOR_SEP: char = '\u{1f}';
|
||||||
|
|
||||||
|
fn encode_meta_cursor(name: &str, scope: &str) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(cursor)
|
||||||
|
.map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||||
|
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||||
|
s.split_once(CURSOR_SEP)
|
||||||
|
.map(|(n, sc)| (n.to_string(), sc.to_string()))
|
||||||
|
.ok_or(SecretsRepoError::InvalidCursor)
|
||||||
|
}
|
||||||
|
|
||||||
/// `(owner_column, owner_uuid)` for binding an owner into a query.
|
/// `(owner_column, owner_uuid)` for binding an owner into a query.
|
||||||
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
|
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
|
||||||
match owner {
|
match owner {
|
||||||
@@ -361,20 +382,27 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||||
let limit = clamp_limit(limit);
|
let limit = clamp_limit(limit);
|
||||||
let last_name = match cursor {
|
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
|
||||||
Some(c) => Some(decode_cursor(c)?),
|
let last = match cursor {
|
||||||
|
Some(c) => Some(decode_meta_cursor(c)?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
let (last_name, last_scope) = match &last {
|
||||||
|
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
let take = i64::from(limit) + 1;
|
let take = i64::from(limit) + 1;
|
||||||
let (col, id) = owner_bind(owner);
|
let (col, id) = owner_bind(owner);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT name, environment_scope, updated_at FROM secrets \
|
"SELECT name, environment_scope, updated_at FROM secrets \
|
||||||
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
|
WHERE {col} = $1 \
|
||||||
ORDER BY name ASC, environment_scope ASC LIMIT $3"
|
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
|
||||||
|
ORDER BY name ASC, environment_scope ASC LIMIT $4"
|
||||||
);
|
);
|
||||||
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(last_name.as_deref())
|
.bind(last_name)
|
||||||
|
.bind(last_scope)
|
||||||
.bind(take)
|
.bind(take)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -389,7 +417,9 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
.collect();
|
.collect();
|
||||||
let next_cursor = if items.len() > limit as usize {
|
let next_cursor = if items.len() > limit as usize {
|
||||||
items.truncate(limit as usize);
|
items.truncate(limit as usize);
|
||||||
items.last().map(|m| encode_cursor(&m.name))
|
items
|
||||||
|
.last()
|
||||||
|
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user