mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 04:04:14 +00:00
889 lines
28 KiB
Rust
889 lines
28 KiB
Rust
use crate::parser::RepoAction;
|
|
use hickory_resolver::TokioAsyncResolver;
|
|
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
use tracing::{debug, warn};
|
|
use tranquil_types::{Did, Nsid};
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ScopeExpansionError {
|
|
#[error("Invalid NSID format: {0}")]
|
|
InvalidNsid(String),
|
|
#[error("Missing definition: {0}")]
|
|
MissingDefinition(String),
|
|
#[error("Unexpected lexicon type: {0}")]
|
|
UnexpectedType(String),
|
|
#[error("DNS resolution failed: {0}")]
|
|
DnsResolution(String),
|
|
#[error("HTTP request failed: {0}")]
|
|
HttpFailed(String),
|
|
#[error("DID resolution failed: {0}")]
|
|
DidResolution(String),
|
|
#[error("Lexicon record not found")]
|
|
RecordNotFound,
|
|
#[error("No valid permissions found in permission-set")]
|
|
EmptyPermissions,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ResolveFailure {
|
|
// Couldn't connect to PDS
|
|
Unreachable,
|
|
// Connected to PDS, but the lexicon record doesn't exist
|
|
NotFound,
|
|
// The NSID is malformed
|
|
Malformed,
|
|
// A lexicon exists, but it isn't a `permission-set` (e.g. a query or record type).
|
|
NotAPermissionSet,
|
|
// Lexicon doc was malformed (no main, no permissions)
|
|
MalformedLexicon,
|
|
// The doc is valid, but grants nothing usable (i.e. empty permissions, or permissions are from a different namespace)
|
|
EmptyPermissions,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ScopeRejection {
|
|
Unrecognized,
|
|
NotRegistered,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RejectedScope {
|
|
pub scope: String,
|
|
pub reason: ScopeRejection,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FailedSet {
|
|
// NSID and aud are left as strings to avoid issues from malformed requests.
|
|
pub given_nsid: String,
|
|
pub given_aud: Option<String>,
|
|
pub reason: ResolveFailure,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ResolvedSetGroup {
|
|
pub nsid: Nsid,
|
|
pub aud: Option<String>,
|
|
pub title: Option<String>,
|
|
pub detail: Option<String>,
|
|
pub expanded: Vec<String>,
|
|
}
|
|
|
|
impl ResolvedSetGroup {
|
|
pub fn include_token(&self) -> String {
|
|
match &self.aud {
|
|
Some(aud) => format!("include:{}?aud={}", self.nsid, aud),
|
|
None => format!("include:{}", self.nsid),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ExpansionOutcome {
|
|
pub passthrough: Vec<String>,
|
|
pub sets: Vec<ResolvedSetGroup>,
|
|
pub failures: Vec<FailedSet>,
|
|
pub rejected: Vec<RejectedScope>,
|
|
}
|
|
|
|
impl ExpansionOutcome {
|
|
pub fn flat_scopes(&self) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut seen = std::collections::HashSet::new();
|
|
for s in self
|
|
.passthrough
|
|
.iter()
|
|
.chain(self.sets.iter().flat_map(|group| group.expanded.iter()))
|
|
{
|
|
if seen.insert(s.as_str()) {
|
|
out.push(s.clone());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn to_scope_string(&self) -> String {
|
|
self.flat_scopes().join(" ")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PlcDocument {
|
|
service: Vec<PlcService>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PlcService {
|
|
id: String,
|
|
#[serde(rename = "serviceEndpoint")]
|
|
service_endpoint: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct XrpcError {
|
|
error: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GetRecordResponse {
|
|
value: LexiconDoc,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LexiconDoc {
|
|
defs: HashMap<String, LexiconDef>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LexiconDef {
|
|
#[serde(rename = "type")]
|
|
def_type: String,
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
detail: Option<String>,
|
|
permissions: Option<Vec<PermissionEntry>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PermissionEntry {
|
|
resource: String,
|
|
action: Option<Vec<String>>,
|
|
collection: Option<Vec<String>>,
|
|
lxm: Option<Vec<String>>,
|
|
aud: Option<String>,
|
|
}
|
|
|
|
pub fn parse_include_scope(rest: &str) -> (&str, Option<&str>) {
|
|
rest.split_once('?')
|
|
.map(|(nsid, params)| {
|
|
let aud = params.split('&').find_map(|p| p.strip_prefix("aud="));
|
|
(nsid, aud)
|
|
})
|
|
.unwrap_or((rest, None))
|
|
}
|
|
|
|
pub struct FetchedSet {
|
|
pub expanded: String,
|
|
pub title: Option<String>,
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
pub async fn fetch_and_expand(
|
|
nsid: &Nsid,
|
|
aud: Option<&str>,
|
|
) -> Result<FetchedSet, ScopeExpansionError> {
|
|
let lexicon = fetch_lexicon_via_atproto(nsid).await?;
|
|
let main_def = lexicon
|
|
.defs
|
|
.get("main")
|
|
.ok_or(ScopeExpansionError::MissingDefinition("main".to_string()))?;
|
|
if main_def.def_type != "permission-set" {
|
|
return Err(ScopeExpansionError::UnexpectedType(
|
|
main_def.def_type.clone(),
|
|
));
|
|
}
|
|
let permissions =
|
|
main_def
|
|
.permissions
|
|
.as_ref()
|
|
.ok_or(ScopeExpansionError::MissingDefinition(
|
|
"permissions".to_string(),
|
|
))?;
|
|
let namespace_authority = extract_namespace_authority(nsid);
|
|
let expanded = build_expanded_scopes(permissions, aud, &namespace_authority);
|
|
if expanded.is_empty() {
|
|
return Err(ScopeExpansionError::EmptyPermissions);
|
|
}
|
|
Ok(FetchedSet {
|
|
expanded,
|
|
title: main_def.title.clone(),
|
|
detail: main_def.detail.clone(),
|
|
})
|
|
}
|
|
|
|
async fn fetch_lexicon_via_atproto(nsid: &Nsid) -> Result<LexiconDoc, ScopeExpansionError> {
|
|
let parts: Vec<&str> = nsid.split('.').collect();
|
|
let authority = parts[..parts.len() - 1]
|
|
.iter()
|
|
.rev()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join(".");
|
|
debug!(nsid = %nsid, authority = %authority, "Resolving lexicon DID authority via DNS");
|
|
|
|
let did = resolve_lexicon_did_authority(&authority).await?;
|
|
debug!(nsid = %nsid, did = %did, "Resolved lexicon DID authority");
|
|
|
|
let pds_endpoint = resolve_did_to_pds(&did).await?;
|
|
debug!(nsid = %nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint");
|
|
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
|
|
|
|
let url = format!(
|
|
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
|
|
pds_endpoint,
|
|
urlencoding::encode(did.as_str()),
|
|
urlencoding::encode(nsid.as_str())
|
|
);
|
|
debug!(nsid = %nsid, url = %url, "Fetching lexicon from PDS");
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.header("Accept", "application/json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
|
|
|
|
let status = response.status();
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
|
|
|
|
if !status.is_success() {
|
|
let not_found = status == reqwest::StatusCode::NOT_FOUND
|
|
|| serde_json::from_str::<XrpcError>(&body)
|
|
.map(|e| e.error.eq_ignore_ascii_case("RecordNotFound"))
|
|
.unwrap_or(false);
|
|
return Err(if not_found {
|
|
ScopeExpansionError::RecordNotFound
|
|
} else {
|
|
ScopeExpansionError::HttpFailed(format!("HTTP {}", status))
|
|
});
|
|
}
|
|
|
|
let record: GetRecordResponse =
|
|
serde_json::from_str(&body).map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
|
|
|
|
Ok(record.value)
|
|
}
|
|
|
|
async fn resolve_lexicon_did_authority(authority: &str) -> Result<Did, ScopeExpansionError> {
|
|
let resolver = TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
|
|
tracing::warn!("falling back to default DNS resolvers: {}", e);
|
|
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
|
|
});
|
|
|
|
let dns_name = format!("_lexicon.{}", authority);
|
|
debug!(dns_name = %dns_name, "Looking up DNS TXT record");
|
|
|
|
let txt_records = resolver
|
|
.txt_lookup(&dns_name)
|
|
.await
|
|
.map_err(|e| ScopeExpansionError::DnsResolution(format!("{}: {}", dns_name, e)))?;
|
|
|
|
txt_records
|
|
.iter()
|
|
.flat_map(|record| record.iter())
|
|
.find_map(|data| {
|
|
let txt = String::from_utf8_lossy(data);
|
|
txt.strip_prefix("did=")
|
|
.and_then(|did| Did::new(did.trim()).ok())
|
|
})
|
|
.ok_or_else(|| {
|
|
ScopeExpansionError::DnsResolution(format!(
|
|
"No valid did= TXT record found at {}",
|
|
dns_name
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn resolve_did_to_pds(did: &Did) -> Result<String, ScopeExpansionError> {
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
|
|
|
|
let url = if did.starts_with("did:plc:") {
|
|
format!("https://plc.directory/{}", did)
|
|
} else if did.starts_with("did:web:") {
|
|
let domain = did.strip_prefix("did:web:").unwrap();
|
|
format!("https://{}/.well-known/did.json", domain)
|
|
} else {
|
|
return Err(ScopeExpansionError::DidResolution(format!(
|
|
"Unsupported DID method: {}",
|
|
did
|
|
)));
|
|
};
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.header("Accept", "application/json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| ScopeExpansionError::DidResolution(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(ScopeExpansionError::DidResolution(format!(
|
|
"HTTP {}",
|
|
response.status()
|
|
)));
|
|
}
|
|
|
|
let doc: PlcDocument = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| ScopeExpansionError::DidResolution(e.to_string()))?;
|
|
|
|
doc.service
|
|
.iter()
|
|
.find(|s| s.id == "#atproto_pds")
|
|
.map(|s| s.service_endpoint.clone())
|
|
.ok_or(ScopeExpansionError::DidResolution(
|
|
"No #atproto_pds service found in DID document".to_string(),
|
|
))
|
|
}
|
|
|
|
fn extract_namespace_authority(nsid: &Nsid) -> String {
|
|
let parts: Vec<&str> = nsid.split('.').collect();
|
|
parts[..parts.len() - 1].join(".")
|
|
}
|
|
|
|
fn is_under_authority(target_nsid: &str, authority: &str) -> bool {
|
|
target_nsid.starts_with(authority)
|
|
&& target_nsid
|
|
.chars()
|
|
.nth(authority.len())
|
|
.is_some_and(|c| c == '.')
|
|
}
|
|
|
|
fn parse_permission_actions(actions: Option<&Vec<String>>) -> Option<BTreeSet<RepoAction>> {
|
|
match actions {
|
|
None => Some(RepoAction::ALL.into_iter().collect()),
|
|
Some(values) => values
|
|
.iter()
|
|
.map(|value| {
|
|
let parsed = RepoAction::parse_str(value);
|
|
if parsed.is_none() {
|
|
warn!(
|
|
action = %value,
|
|
"skipping permission entry with unrecognized repo action"
|
|
);
|
|
}
|
|
parsed
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn build_expanded_scopes(
|
|
permissions: &[PermissionEntry],
|
|
default_aud: Option<&str>,
|
|
namespace_authority: &str,
|
|
) -> String {
|
|
let mut ungrouped_repo_scopes: BTreeMap<String, BTreeSet<RepoAction>> = BTreeMap::new();
|
|
let mut rpc_scopes: Vec<String> = Vec::new();
|
|
|
|
permissions
|
|
.iter()
|
|
.for_each(|perm| match perm.resource.as_str() {
|
|
"repo" => {
|
|
if let Some(collections) = &perm.collection
|
|
&& let Some(actions) = parse_permission_actions(perm.action.as_ref())
|
|
&& !actions.is_empty()
|
|
{
|
|
collections
|
|
.iter()
|
|
.filter(|coll| is_under_authority(coll, namespace_authority))
|
|
.for_each(|coll| {
|
|
ungrouped_repo_scopes
|
|
.entry(coll.to_string())
|
|
.or_default()
|
|
.extend(actions.iter().copied());
|
|
});
|
|
}
|
|
}
|
|
"rpc" => {
|
|
if let Some(lxms) = &perm.lxm {
|
|
let perm_aud = perm.aud.as_deref().or(default_aud);
|
|
|
|
lxms.iter()
|
|
.filter(|lxm| is_under_authority(lxm, namespace_authority))
|
|
.for_each(|lxm| {
|
|
let scope = match perm_aud {
|
|
Some(aud) => format!("rpc:{}?aud={}", lxm, aud),
|
|
None => format!("rpc:{}", lxm),
|
|
};
|
|
|
|
if !rpc_scopes.contains(&scope) {
|
|
rpc_scopes.push(scope);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
});
|
|
|
|
let grouped_repo_scopes: Vec<String> = ungrouped_repo_scopes
|
|
.iter()
|
|
.map(|(repo, actions)| {
|
|
let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect();
|
|
|
|
format!("repo:{}?action={}", repo, rendered.join("&action="))
|
|
})
|
|
.collect();
|
|
|
|
let combined_repo_scopes = grouped_repo_scopes.join(" ");
|
|
let combined_rpc_scopes = rpc_scopes.join(" ");
|
|
|
|
format!("{} {}", combined_repo_scopes, combined_rpc_scopes)
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_include_scope() {
|
|
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp");
|
|
assert_eq!(nsid, "io.atcr.authFullApp");
|
|
assert_eq!(aud, None);
|
|
|
|
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app");
|
|
assert_eq!(nsid, "io.atcr.authFullApp");
|
|
assert_eq!(aud, Some("did:web:api.bsky.app"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_include_scope_with_multiple_params() {
|
|
let (nsid, aud) =
|
|
parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux");
|
|
assert_eq!(nsid, "io.atcr.authFullApp");
|
|
assert_eq!(aud, Some("did:web:example.com"));
|
|
}
|
|
|
|
fn nsid(s: &str) -> Nsid {
|
|
s.parse().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_namespace_authority() {
|
|
assert_eq!(
|
|
extract_namespace_authority(&nsid("io.atcr.authFullApp")),
|
|
"io.atcr"
|
|
);
|
|
assert_eq!(
|
|
extract_namespace_authority(&nsid("app.bsky.authFullApp")),
|
|
"app.bsky"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_namespace_authority_deep_nesting() {
|
|
assert_eq!(
|
|
extract_namespace_authority(&nsid("io.atcr.sailor.star.collection")),
|
|
"io.atcr.sailor.star"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_under_authority() {
|
|
assert!(is_under_authority("io.atcr.manifest", "io.atcr"));
|
|
assert!(is_under_authority("io.atcr.sailor.star", "io.atcr"));
|
|
assert!(!is_under_authority("app.bsky.feed.post", "io.atcr"));
|
|
assert!(!is_under_authority("io.atcr", "io.atcr"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_under_authority_prefix_collision() {
|
|
assert!(!is_under_authority("io.atcritical.something", "io.atcr"));
|
|
assert!(is_under_authority("io.atcr.something", "io.atcr"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_repo() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["create".to_string(), "delete".to_string()]),
|
|
collection: Some(vec![
|
|
"io.atcr.manifest".to_string(),
|
|
"io.atcr.sailor.star".to_string(),
|
|
"app.bsky.feed.post".to_string(),
|
|
]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(
|
|
expanded,
|
|
"repo:io.atcr.manifest?action=create&action=delete \
|
|
repo:io.atcr.sailor.star?action=create&action=delete"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_repo_default_actions() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: None,
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(
|
|
expanded,
|
|
"repo:io.atcr.manifest?action=create&action=update&action=delete"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_repo_omitted_action_grants_all() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: None,
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(
|
|
expanded, "repo:io.atcr.manifest?action=create&action=update&action=delete",
|
|
"an omitted action list means all actions"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_repo_empty_action_list_skips_entry() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec![]),
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(
|
|
expanded.is_empty(),
|
|
"an explicitly empty action list is invalid, so the entry is skipped rather \
|
|
than expanded to all actions or emitted as a bare `?action=`, got: {expanded}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_is_deterministic() {
|
|
let permissions = vec![
|
|
PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["create".to_string()]),
|
|
collection: Some(vec![
|
|
"io.atcr.sailor.star".to_string(),
|
|
"io.atcr.manifest".to_string(),
|
|
"io.atcr.blob".to_string(),
|
|
]),
|
|
lxm: None,
|
|
aud: None,
|
|
},
|
|
PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec![
|
|
"io.atcr.getManifest".to_string(),
|
|
"io.atcr.listTags".to_string(),
|
|
]),
|
|
aud: Some("*".to_string()),
|
|
},
|
|
];
|
|
|
|
let first = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(
|
|
first,
|
|
"repo:io.atcr.blob?action=create repo:io.atcr.manifest?action=create \
|
|
repo:io.atcr.sailor.star?action=create \
|
|
rpc:io.atcr.getManifest?aud=* rpc:io.atcr.listTags?aud=*"
|
|
);
|
|
|
|
for _ in 0..16 {
|
|
assert_eq!(build_expanded_scopes(&permissions, None, "io.atcr"), first);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_dedupes_and_canonicalizes_actions() {
|
|
let permissions = vec![
|
|
PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["delete".to_string(), "create".to_string()]),
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
},
|
|
PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["create".to_string(), "update".to_string()]),
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
},
|
|
PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec![
|
|
"io.atcr.getManifest".to_string(),
|
|
"io.atcr.getManifest".to_string(),
|
|
]),
|
|
aud: Some("*".to_string()),
|
|
},
|
|
];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(
|
|
expanded,
|
|
"repo:io.atcr.manifest?action=create&action=update&action=delete \
|
|
rpc:io.atcr.getManifest?aud=*"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_repo_unrecognized_action_skips_entry() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["read".to_string()]),
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(
|
|
expanded.is_empty(),
|
|
"an unrecognized repo action must not expand to all actions, got: {expanded}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_rpc() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec![
|
|
"io.atcr.getManifest".to_string(),
|
|
"com.atproto.repo.getRecord".to_string(),
|
|
]),
|
|
aud: Some("*".to_string()),
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*"));
|
|
assert!(!expanded.contains("com.atproto.repo.getRecord"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_rpc_with_default_aud() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded =
|
|
build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr");
|
|
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_rpc_no_aud() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(expanded, "rpc:io.atcr.getManifest");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_mixed_permissions() {
|
|
let permissions = vec![
|
|
PermissionEntry {
|
|
resource: "repo".to_string(),
|
|
action: Some(vec!["create".to_string()]),
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
},
|
|
PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]),
|
|
aud: Some("*".to_string()),
|
|
},
|
|
];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
|
|
assert!(!expanded.contains("com.atproto.repo.getRecord"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_rpc_cannot_escape_namespace() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec![
|
|
"com.atproto.repo.deleteRecord".to_string(),
|
|
"chat.bsky.convo.sendMessage".to_string(),
|
|
"io.atcrEVIL.getManifest".to_string(),
|
|
]),
|
|
aud: Some("*".to_string()),
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(
|
|
expanded.is_empty(),
|
|
"cross-namespace lxm values must all be dropped, got: {expanded}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_rpc_allows_child_namespace() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "rpc".to_string(),
|
|
action: None,
|
|
collection: None,
|
|
lxm: Some(vec!["io.atcr.sailor.star.getThing".to_string()]),
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert_eq!(expanded, "rpc:io.atcr.sailor.star.getThing");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_unknown_resource_ignored() {
|
|
let permissions = vec![PermissionEntry {
|
|
resource: "unknown".to_string(),
|
|
action: None,
|
|
collection: Some(vec!["io.atcr.manifest".to_string()]),
|
|
lxm: None,
|
|
aud: None,
|
|
}];
|
|
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(expanded.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expanded_scopes_empty_permissions() {
|
|
let permissions: Vec<PermissionEntry> = vec![];
|
|
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
|
|
assert!(expanded.is_empty());
|
|
}
|
|
|
|
fn dns_authority(nsid: &str) -> String {
|
|
let parts: Vec<&str> = nsid.split('.').collect();
|
|
parts[..parts.len() - 1]
|
|
.iter()
|
|
.rev()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join(".")
|
|
}
|
|
|
|
#[test]
|
|
fn test_nsid_authority_extraction_for_dns() {
|
|
assert_eq!(dns_authority("io.atcr.authFullApp"), "atcr.io");
|
|
assert_eq!(dns_authority("app.bsky.feed.post"), "feed.bsky.app");
|
|
assert_eq!(
|
|
dns_authority("community.lexicon.bookmarks.authManageBookmarks"),
|
|
"bookmarks.lexicon.community"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn expansion_outcome_flat_scopes_and_string() {
|
|
let out = ExpansionOutcome {
|
|
passthrough: vec!["atproto".into()],
|
|
sets: vec![ResolvedSetGroup {
|
|
nsid: Nsid::new("io.atcr.authFullApp").unwrap(),
|
|
aud: None,
|
|
title: Some("T".into()),
|
|
detail: None,
|
|
expanded: vec![
|
|
"repo:io.atcr.manifest?action=create".into(),
|
|
"rpc:io.atcr.getManifest".into(),
|
|
],
|
|
}],
|
|
failures: vec![FailedSet {
|
|
given_nsid: "nonexistent.fake.permissionSet".into(),
|
|
given_aud: None,
|
|
reason: ResolveFailure::NotFound,
|
|
}],
|
|
rejected: vec![],
|
|
};
|
|
let flat = out.flat_scopes();
|
|
assert_eq!(
|
|
flat,
|
|
vec![
|
|
"atproto",
|
|
"repo:io.atcr.manifest?action=create",
|
|
"rpc:io.atcr.getManifest"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
out.to_scope_string(),
|
|
"atproto repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flat_scopes_dedupes() {
|
|
let out = ExpansionOutcome {
|
|
passthrough: vec!["repo:x".into()],
|
|
sets: vec![ResolvedSetGroup {
|
|
nsid: Nsid::new("io.atcr.authFullApp").unwrap(),
|
|
aud: None,
|
|
title: None,
|
|
detail: None,
|
|
expanded: vec!["repo:x".into(), "rpc:io.atcr.getManifest".into()],
|
|
}],
|
|
failures: vec![],
|
|
rejected: vec![],
|
|
};
|
|
let flat = out.flat_scopes();
|
|
assert_eq!(flat, vec!["repo:x", "rpc:io.atcr.getManifest"]);
|
|
assert_eq!(out.to_scope_string(), "repo:x rpc:io.atcr.getManifest");
|
|
}
|
|
|
|
#[test]
|
|
fn test_lexicon_def_captures_title_and_detail() {
|
|
let json = serde_json::json!({
|
|
"defs": { "main": {
|
|
"type": "permission-set",
|
|
"title": "Basic App",
|
|
"detail": "Posts and interactions",
|
|
"permissions": [{ "resource": "repo", "collection": ["io.atcr.manifest"] }]
|
|
}}
|
|
});
|
|
let doc: LexiconDoc = serde_json::from_value(json).unwrap();
|
|
let main = doc.defs.get("main").unwrap();
|
|
assert_eq!(main.title.as_deref(), Some("Basic App"));
|
|
assert_eq!(main.detail.as_deref(), Some("Posts and interactions"));
|
|
}
|
|
}
|