-2
.gitignore
-2
.gitignore
-2
README.md
-2
README.md
···
12
12
13
13
## What's different about Tranquil PDS
14
14
15
-
This software isn't an afterthought by a company with limited resources.
16
-
17
15
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
18
16
19
17
The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor.
-3
ref_pds_downloader.sh
-3
ref_pds_downloader.sh
+12
-1
src/oauth/endpoints/token/grants.rs
+12
-1
src/oauth/endpoints/token/grants.rs
···
7
7
client::{ClientMetadataCache, verify_client_auth},
8
8
db::{self, RefreshTokenLookup},
9
9
dpop::DPoPVerifier,
10
+
scopes::expand_include_scopes,
10
11
};
11
12
use crate::state::AppState;
12
13
use axum::Json;
···
122
123
let refresh_token = RefreshToken::generate();
123
124
let now = Utc::now();
124
125
125
-
let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did {
126
+
let (raw_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did {
126
127
let grant = delegation::get_delegation(&state.db, &did, controller)
127
128
.await
128
129
.ok()
···
137
138
(Some(intersected), Some(controller.clone()))
138
139
} else {
139
140
(auth_request.parameters.scope.clone(), None)
141
+
};
142
+
143
+
let final_scope = if let Some(ref scope) = raw_scope {
144
+
if scope.contains("include:") {
145
+
Some(expand_include_scopes(scope).await)
146
+
} else {
147
+
raw_scope
148
+
}
149
+
} else {
150
+
raw_scope
140
151
};
141
152
142
153
let access_token = create_access_token_with_delegation(
+2
src/oauth/scopes/mod.rs
+2
src/oauth/scopes/mod.rs
···
1
1
mod definitions;
2
2
mod error;
3
3
mod parser;
4
+
mod permission_set;
4
5
mod permissions;
5
6
6
7
pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition};
···
9
10
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope,
10
11
ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string,
11
12
};
13
+
pub use permission_set::expand_include_scopes;
12
14
pub use permissions::ScopePermissions;
+172
src/oauth/scopes/permission_set.rs
+172
src/oauth/scopes/permission_set.rs
···
1
+
use reqwest::Client;
2
+
use serde::Deserialize;
3
+
use std::collections::HashMap;
4
+
use std::sync::LazyLock;
5
+
use tokio::sync::RwLock;
6
+
use tracing::{debug, warn};
7
+
8
+
static LEXICON_CACHE: LazyLock<RwLock<HashMap<String, CachedLexicon>>> =
9
+
LazyLock::new(|| RwLock::new(HashMap::new()));
10
+
11
+
#[derive(Clone)]
12
+
struct CachedLexicon {
13
+
expanded_scope: String,
14
+
cached_at: std::time::Instant,
15
+
}
16
+
17
+
const CACHE_TTL_SECS: u64 = 3600;
18
+
19
+
#[derive(Debug, Deserialize)]
20
+
struct LexiconDoc {
21
+
defs: HashMap<String, LexiconDef>,
22
+
}
23
+
24
+
#[derive(Debug, Deserialize)]
25
+
struct LexiconDef {
26
+
#[serde(rename = "type")]
27
+
def_type: String,
28
+
permissions: Option<Vec<PermissionEntry>>,
29
+
}
30
+
31
+
#[derive(Debug, Deserialize)]
32
+
struct PermissionEntry {
33
+
resource: String,
34
+
collection: Option<Vec<String>>,
35
+
}
36
+
37
+
pub async fn expand_include_scopes(scope_string: &str) -> String {
38
+
let futures: Vec<_> = scope_string
39
+
.split_whitespace()
40
+
.map(|scope| async move {
41
+
match scope.strip_prefix("include:") {
42
+
Some(nsid) => {
43
+
let nsid_base = nsid.split('?').next().unwrap_or(nsid);
44
+
expand_permission_set(nsid_base).await.unwrap_or_else(|e| {
45
+
warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
46
+
scope.to_string()
47
+
})
48
+
}
49
+
None => scope.to_string(),
50
+
}
51
+
})
52
+
.collect();
53
+
54
+
futures::future::join_all(futures).await.join(" ")
55
+
}
56
+
57
+
async fn expand_permission_set(nsid: &str) -> Result<String, String> {
58
+
{
59
+
let cache = LEXICON_CACHE.read().await;
60
+
if let Some(cached) = cache.get(nsid) {
61
+
if cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS {
62
+
debug!(nsid, "Using cached permission set expansion");
63
+
return Ok(cached.expanded_scope.clone());
64
+
}
65
+
}
66
+
}
67
+
68
+
let parts: Vec<&str> = nsid.split('.').collect();
69
+
if parts.len() < 3 {
70
+
return Err(format!("Invalid NSID format: {}", nsid));
71
+
}
72
+
73
+
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
74
+
let domain = domain_parts.join(".");
75
+
let path = parts[2..].join("/");
76
+
77
+
let url = format!("https://{}/lexicons/{}.json", domain, path);
78
+
debug!(nsid, url = %url, "Fetching permission set lexicon");
79
+
80
+
let client = Client::builder()
81
+
.timeout(std::time::Duration::from_secs(10))
82
+
.build()
83
+
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
84
+
85
+
let response = client
86
+
.get(&url)
87
+
.header("Accept", "application/json")
88
+
.send()
89
+
.await
90
+
.map_err(|e| format!("Failed to fetch lexicon: {}", e))?;
91
+
92
+
if !response.status().is_success() {
93
+
return Err(format!(
94
+
"Failed to fetch lexicon: HTTP {}",
95
+
response.status()
96
+
));
97
+
}
98
+
99
+
let lexicon: LexiconDoc = response
100
+
.json()
101
+
.await
102
+
.map_err(|e| format!("Failed to parse lexicon: {}", e))?;
103
+
104
+
let main_def = lexicon
105
+
.defs
106
+
.get("main")
107
+
.ok_or("Missing 'main' definition in lexicon")?;
108
+
109
+
if main_def.def_type != "permission-set" {
110
+
return Err(format!(
111
+
"Expected permission-set type, got: {}",
112
+
main_def.def_type
113
+
));
114
+
}
115
+
116
+
let permissions = main_def
117
+
.permissions
118
+
.as_ref()
119
+
.ok_or("Missing permissions in permission-set")?;
120
+
121
+
let mut collections: Vec<String> = permissions
122
+
.iter()
123
+
.filter(|perm| perm.resource == "repo")
124
+
.filter_map(|perm| perm.collection.as_ref())
125
+
.flatten()
126
+
.cloned()
127
+
.collect();
128
+
129
+
if collections.is_empty() {
130
+
return Err("No repo collections found in permission-set".to_string());
131
+
}
132
+
133
+
collections.sort();
134
+
135
+
let collection_params: Vec<String> = collections
136
+
.iter()
137
+
.map(|c| format!("collection={}", c))
138
+
.collect();
139
+
140
+
let expanded = format!("repo?{}", collection_params.join("&"));
141
+
142
+
{
143
+
let mut cache = LEXICON_CACHE.write().await;
144
+
cache.insert(
145
+
nsid.to_string(),
146
+
CachedLexicon {
147
+
expanded_scope: expanded.clone(),
148
+
cached_at: std::time::Instant::now(),
149
+
},
150
+
);
151
+
}
152
+
153
+
debug!(nsid, expanded = %expanded, "Successfully expanded permission set");
154
+
Ok(expanded)
155
+
}
156
+
157
+
#[cfg(test)]
158
+
mod tests {
159
+
use super::*;
160
+
161
+
#[test]
162
+
fn test_nsid_to_url() {
163
+
let nsid = "io.atcr.authFullApp";
164
+
let parts: Vec<&str> = nsid.split('.').collect();
165
+
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
166
+
let domain = domain_parts.join(".");
167
+
let path = parts[2..].join("/");
168
+
169
+
assert_eq!(domain, "atcr.io");
170
+
assert_eq!(path, "authFullApp");
171
+
}
172
+
}