@@ -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