Skip to content

Commit 1dfe430

Browse files
committed
feat(provider): implement scoped provider name uniqueness
Allow multiple users to create providers with the same visible name by storing them with an owner-prefixed DB key ({owner}/{name}). Resolution uses two-pass lookup: try owned first, fall back to shared. Key changes: - ownership.rs: scoped_name, display_name, owner_prefix, scoped_name_for_principal, resolve_scoped_name helpers + unit tests - provider.rs: create stamps scoped key, get/update/delete resolve via two-pass, list filters by owner, responses strip prefix - sandbox.rs: spec.providers stores DB keys, attach/detach resolve user-visible names, all response paths strip prefixes Zero DB migration required — existing partial unique index on (object_type, name) already supports scoped keys. Closes: rossoctl/rossoctl#1996 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
1 parent b3147e9 commit 1dfe430

3 files changed

Lines changed: 641 additions & 113 deletions

File tree

crates/openshell-server/src/auth/ownership.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,109 @@ fn is_valid_label_value(value: &str) -> bool {
166166
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
167167
}
168168

169+
// ---------------------------------------------------------------------------
170+
// Scoped name helpers
171+
// ---------------------------------------------------------------------------
172+
173+
/// Separator between owner and object name in scoped DB keys.
174+
const SCOPE_SEPARATOR: char = '/';
175+
176+
/// Build a scoped DB key: `"{owner}/{name}"`.
177+
///
178+
/// The owner segment is the sanitized subject (UUID or hex hash), guaranteed to
179+
/// contain no `/`. The user-visible name may contain `/` only if we ever allow
180+
/// it (currently validation rejects it), but the *first* `/` is always the
181+
/// owner boundary.
182+
pub fn scoped_name(owner: &str, name: &str) -> String {
183+
format!("{owner}{SCOPE_SEPARATOR}{name}")
184+
}
185+
186+
/// Extract the user-visible name from a potentially scoped DB key.
187+
///
188+
/// If the key contains a `/`, the part after the first `/` is the display name.
189+
/// If no `/`, the key *is* the display name (shared/legacy provider).
190+
pub fn display_name(db_key: &str) -> &str {
191+
match db_key.find(SCOPE_SEPARATOR) {
192+
Some(pos) => &db_key[pos + 1..],
193+
None => db_key,
194+
}
195+
}
196+
197+
/// Extract the owner prefix from a scoped DB key, if present.
198+
pub fn owner_prefix(db_key: &str) -> Option<&str> {
199+
db_key.find(SCOPE_SEPARATOR).map(|pos| &db_key[..pos])
200+
}
201+
202+
/// Build the scoped DB key for the given principal, or return the raw name for
203+
/// anonymous/admin callers.
204+
pub fn scoped_name_for_principal(
205+
name: &str,
206+
principal: Option<&Principal>,
207+
admin_role: &str,
208+
) -> Result<String, Status> {
209+
let Some(identity) = principal_identity(principal) else {
210+
return Ok(name.to_string());
211+
};
212+
213+
if !admin_role.is_empty() && identity.roles.iter().any(|r| r == admin_role) {
214+
return Ok(name.to_string());
215+
}
216+
217+
let owner_value = sanitize_subject(&identity.subject)?;
218+
Ok(scoped_name(&owner_value, name))
219+
}
220+
221+
/// Resolve a provider name with owner-scoped fallback.
222+
///
223+
/// Resolution order:
224+
/// 1. Try `{owner}/{name}` (user's own provider)
225+
/// 2. Fall back to `{name}` (shared provider, no owner prefix)
226+
///
227+
/// Admin callers and anonymous principals resolve the raw name directly.
228+
pub async fn resolve_scoped_name(
229+
store: &crate::persistence::Store,
230+
object_type: &str,
231+
name: &str,
232+
principal: Option<&Principal>,
233+
admin_role: &str,
234+
) -> Result<Option<crate::persistence::ObjectRecord>, Status> {
235+
let Some(identity) = principal_identity(principal) else {
236+
// Anonymous/none → direct lookup (backward compat)
237+
return store
238+
.get_by_name(object_type, name)
239+
.await
240+
.map_err(|e| Status::internal(format!("fetch by name failed: {e}")));
241+
};
242+
243+
// Admin with explicit scoped name (contains '/') → direct lookup
244+
let is_admin = !admin_role.is_empty() && identity.roles.iter().any(|r| r == admin_role);
245+
if is_admin && name.contains(SCOPE_SEPARATOR) {
246+
return store
247+
.get_by_name(object_type, name)
248+
.await
249+
.map_err(|e| Status::internal(format!("fetch by name failed: {e}")));
250+
}
251+
252+
// Non-admin (or admin without explicit scope): try owned first
253+
if !is_admin {
254+
let owner_value = sanitize_subject(&identity.subject)?;
255+
let owned_key = scoped_name(&owner_value, name);
256+
let owned = store
257+
.get_by_name(object_type, &owned_key)
258+
.await
259+
.map_err(|e| Status::internal(format!("fetch owned provider failed: {e}")))?;
260+
if owned.is_some() {
261+
return Ok(owned);
262+
}
263+
}
264+
265+
// Fall back to shared (unscoped) name
266+
store
267+
.get_by_name(object_type, name)
268+
.await
269+
.map_err(|e| Status::internal(format!("fetch shared provider failed: {e}")))
270+
}
271+
169272
// ---------------------------------------------------------------------------
170273
// Tests
171274
// ---------------------------------------------------------------------------
@@ -370,4 +473,61 @@ mod tests {
370473
let result = owner_selector(None, "", "openshell-admin").unwrap();
371474
assert_eq!(result, "");
372475
}
476+
477+
// ---- scoped name helpers ----
478+
479+
#[test]
480+
fn scoped_name_construction() {
481+
assert_eq!(scoped_name("alice-uuid-1234", "openai"), "alice-uuid-1234/openai");
482+
}
483+
484+
#[test]
485+
fn display_name_strips_owner() {
486+
assert_eq!(display_name("alice-uuid-1234/openai"), "openai");
487+
}
488+
489+
#[test]
490+
fn display_name_shared_unchanged() {
491+
assert_eq!(display_name("openai"), "openai");
492+
}
493+
494+
#[test]
495+
fn owner_prefix_extracts_owner() {
496+
assert_eq!(owner_prefix("alice-uuid-1234/openai"), Some("alice-uuid-1234"));
497+
}
498+
499+
#[test]
500+
fn owner_prefix_none_for_shared() {
501+
assert_eq!(owner_prefix("openai"), None);
502+
}
503+
504+
#[test]
505+
fn scoped_name_for_principal_user() {
506+
let principal = alice();
507+
let result =
508+
scoped_name_for_principal("openai", Some(&principal), "openshell-admin").unwrap();
509+
assert_eq!(result, "alice-uuid-1234/openai");
510+
}
511+
512+
#[test]
513+
fn scoped_name_for_principal_admin_unscoped() {
514+
let principal = admin_principal();
515+
let result =
516+
scoped_name_for_principal("openai", Some(&principal), "openshell-admin").unwrap();
517+
assert_eq!(result, "openai");
518+
}
519+
520+
#[test]
521+
fn scoped_name_for_principal_anonymous_unscoped() {
522+
let result =
523+
scoped_name_for_principal("openai", Some(&Principal::Anonymous), "openshell-admin")
524+
.unwrap();
525+
assert_eq!(result, "openai");
526+
}
527+
528+
#[test]
529+
fn scoped_name_for_principal_none_unscoped() {
530+
let result = scoped_name_for_principal("openai", None, "openshell-admin").unwrap();
531+
assert_eq!(result, "openai");
532+
}
373533
}

0 commit comments

Comments
 (0)