Skip to content
Merged
203 changes: 203 additions & 0 deletions crates/openshell-server/src/auth/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,146 @@ fn is_valid_label_value(value: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}

// ---------------------------------------------------------------------------
// Scoped name helpers
// ---------------------------------------------------------------------------

/// Separator between owner and object name in scoped DB keys.
const SCOPE_SEPARATOR: char = '/';

/// Build a scoped DB key: `"{owner}/{name}"`.
///
/// The owner segment is the sanitized subject (UUID or hex hash), guaranteed to
/// contain no `/`. The user-visible name may contain `/` only if we ever allow
/// it (currently validation rejects it), but the *first* `/` is always the
/// owner boundary.
pub fn scoped_name(owner: &str, name: &str) -> String {
format!("{owner}{SCOPE_SEPARATOR}{name}")
}

/// Extract the user-visible name from a potentially scoped DB key.
///
/// If the key contains a `/`, the part after the first `/` is the display name.
/// If no `/`, the key *is* the display name (shared/legacy provider).
pub fn display_name(db_key: &str) -> &str {
db_key
.find(SCOPE_SEPARATOR)
.map_or(db_key, |pos| &db_key[pos + 1..])
}

/// Extract the owner prefix from a scoped DB key, if present.
#[allow(dead_code)] // Used in tests; needed for admin addressability (TODO).
pub fn owner_prefix(db_key: &str) -> Option<&str> {
db_key.find(SCOPE_SEPARATOR).map(|pos| &db_key[..pos])
}

/// Build the scoped DB key for the given principal, or return the raw name for
/// anonymous/admin callers.
#[allow(dead_code)] // Used in tests; needed for admin addressability (TODO).
pub fn scoped_name_for_principal(
name: &str,
principal: Option<&Principal>,
admin_role: &str,
) -> Result<String, Status> {
let Some(identity) = principal_identity(principal) else {
return Ok(name.to_string());
};

if !admin_role.is_empty() && identity.roles.iter().any(|r| r == admin_role) {
return Ok(name.to_string());
}

let owner_value = sanitize_subject(&identity.subject)?;
Ok(scoped_name(&owner_value, name))
}

/// Resolve a provider name with owner-scoped fallback.
///
/// Resolution order:
/// 1. Try `{owner}/{name}` (user's own provider) — non-admin only
/// 2. Fall back to `{name}` (shared/legacy provider, no owner prefix)
/// - If found and owned by another user: `PermissionDenied` (non-admin)
/// 3. Suffix scan for `*/{name}` (cross-user detection)
/// - If found and admin: return the match
/// - If found and non-admin: `PermissionDenied`
///
/// Admin callers with explicit scoped name (contains '/') resolve directly.
/// Anonymous principals resolve the raw name directly (backward compat).
pub async fn resolve_scoped_name(
store: &crate::persistence::Store,
object_type: &str,
name: &str,
principal: Option<&Principal>,
admin_role: &str,
) -> Result<Option<crate::persistence::ObjectRecord>, Status> {
let Some(identity) = principal_identity(principal) else {
// Anonymous/none → direct lookup (backward compat)
return store
.get_by_name(object_type, name)
.await
.map_err(|e| Status::internal(format!("fetch by name failed: {e}")));
};

// Admin with explicit scoped name (contains '/') → direct lookup
let is_admin = !admin_role.is_empty() && identity.roles.iter().any(|r| r == admin_role);
if is_admin && name.contains(SCOPE_SEPARATOR) {
return store
.get_by_name(object_type, name)
.await
.map_err(|e| Status::internal(format!("fetch by name failed: {e}")));
}

let caller_owner = sanitize_subject(&identity.subject)?;

// Step 1: Non-admin tries owned lookup first
if !is_admin {
let owned_key = scoped_name(&caller_owner, name);
let owned = store
.get_by_name(object_type, &owned_key)
.await
.map_err(|e| Status::internal(format!("fetch owned provider failed: {e}")))?;
if owned.is_some() {
return Ok(owned);
}
}

// Step 2: Fall back to shared (unscoped) name
let shared = store
.get_by_name(object_type, name)
.await
.map_err(|e| Status::internal(format!("fetch shared provider failed: {e}")))?;

if let Some(ref record) = shared {
if let Some(ref labels_json) = record.labels {
let labels: HashMap<String, String> =
serde_json::from_str(labels_json).unwrap_or_default();
if let Some(record_owner) = labels.get(OWNER_LABEL)
&& *record_owner != caller_owner
&& !is_admin
{
return Err(Status::permission_denied(
"provider is owned by another user",
));
}
}
return Ok(shared);
}

// Step 3: Both missed — suffix scan for cross-user detection.
// Admin callers get access to any user's provider. Non-admin callers
// get None (surfaced as NotFound) for information-hiding security.
let candidates = store
.find_by_name_suffix(object_type, name)
.await
.map_err(|e| Status::internal(format!("suffix search failed: {e}")))?;

if is_admin && let Some(record) = candidates.into_iter().next() {
return Ok(Some(record));
}

Ok(None)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -370,4 +510,67 @@ mod tests {
let result = owner_selector(None, "", "openshell-admin").unwrap();
assert_eq!(result, "");
}

// ---- scoped name helpers ----

#[test]
fn scoped_name_construction() {
assert_eq!(
scoped_name("alice-uuid-1234", "openai"),
"alice-uuid-1234/openai"
);
}

#[test]
fn display_name_strips_owner() {
assert_eq!(display_name("alice-uuid-1234/openai"), "openai");
}

#[test]
fn display_name_shared_unchanged() {
assert_eq!(display_name("openai"), "openai");
}

#[test]
fn owner_prefix_extracts_owner() {
assert_eq!(
owner_prefix("alice-uuid-1234/openai"),
Some("alice-uuid-1234")
);
}

#[test]
fn owner_prefix_none_for_shared() {
assert_eq!(owner_prefix("openai"), None);
}

#[test]
fn scoped_name_for_principal_user() {
let principal = alice();
let result =
scoped_name_for_principal("openai", Some(&principal), "openshell-admin").unwrap();
assert_eq!(result, "alice-uuid-1234/openai");
}

#[test]
fn scoped_name_for_principal_admin_unscoped() {
let principal = admin_principal();
let result =
scoped_name_for_principal("openai", Some(&principal), "openshell-admin").unwrap();
assert_eq!(result, "openai");
}

#[test]
fn scoped_name_for_principal_anonymous_unscoped() {
let result =
scoped_name_for_principal("openai", Some(&Principal::Anonymous), "openshell-admin")
.unwrap();
assert_eq!(result, "openai");
}

#[test]
fn scoped_name_for_principal_none_unscoped() {
let result = scoped_name_for_principal("openai", None, "openshell-admin").unwrap();
assert_eq!(result, "openai");
}
}
Loading
Loading