Commit

Author:

Hash:

Timestamp:

+231 -65 +/-8 browse

Kevin Schoon [me@kevinschoon.com]

1144320ef666f595bb3649993f03b4a0c4de78b7

Sun, 13 Jul 2025 21:04:16 +0000 (4 months ago)

implement the concept of user identities
implement the concept of user identities

This begins the process of implementing the concept of "identities" which
are configuration associated with a system account on an Ayllu server. Rather
than managing separate accounts with traditional user sign-ups, password
resets, etc. Ayllu integrates directly with the host OS's account system.

Identities now delegate user configuration to the home directories of managed
users. So for example an Ayllu server might be running under the `ayllu` user
system account but the global configuration delegates an identity to a user
named `demo`. Ayllu will attempt to load the user's configuration from their
home directory which may contain repository collections.
1diff --git a/Cargo.lock b/Cargo.lock
2index 73ada26..feb3abb 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -339,6 +339,7 @@ dependencies = [
6 "log",
7 "mime",
8 "mime_guess",
9+ "nix",
10 "openssh-keys",
11 "plotters",
12 "quick-xml 0.38.0",
13 @@ -549,6 +550,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
14 checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
15
16 [[package]]
17+ name = "cfg_aliases"
18+ version = "0.2.1"
19+ source = "registry+https://github.com/rust-lang/crates.io-index"
20+ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
21+
22+ [[package]]
23 name = "chrono"
24 version = "0.4.41"
25 source = "registry+https://github.com/rust-lang/crates.io-index"
26 @@ -2159,6 +2166,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
27 checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
28
29 [[package]]
30+ name = "nix"
31+ version = "0.30.1"
32+ source = "registry+https://github.com/rust-lang/crates.io-index"
33+ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
34+ dependencies = [
35+ "bitflags 2.9.1",
36+ "cfg-if",
37+ "cfg_aliases",
38+ "libc",
39+ ]
40+
41+ [[package]]
42 name = "nu-ansi-term"
43 version = "0.46.0"
44 source = "registry+https://github.com/rust-lang/crates.io-index"
45 diff --git a/ayllu/Cargo.toml b/ayllu/Cargo.toml
46index 98f3fe2..8d7b6f2 100644
47--- a/ayllu/Cargo.toml
48+++ b/ayllu/Cargo.toml
49 @@ -59,6 +59,7 @@ webfinger-rs = { version = "0.0.13", features = ["axum"] }
50 quick-xml = { version = "0.38.0", features = ["encoding"] }
51 askama = { version = "0.14.0" }
52 openssh-keys = "0.6.4"
53+ nix = { version = "0.30.1", default-features = false, features = ["user"] }
54
55 [build-dependencies]
56 cc="*"
57 diff --git a/ayllu/src/config.rs b/ayllu/src/config.rs
58index d52b9fa..0bba471 100644
59--- a/ayllu/src/config.rs
60+++ b/ayllu/src/config.rs
61 @@ -1,9 +1,10 @@
62- use std::collections::HashMap;
63- use std::error::Error;
64+ use std::error::Error as StdError;
65 use std::fs::metadata;
66+ use std::path::Path;
67+ use std::{collections::HashMap, path::PathBuf};
68 use url::Url;
69
70- use ayllu_config::Configurable;
71+ use ayllu_config::{Configurable, Reader};
72
73 use serde::{Deserialize, Serialize};
74
75 @@ -11,9 +12,7 @@ pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
76
77 // names that cannot be used in collections because they conflict with certain
78 // internal urls within the service.
79- const BANNED_COLLECTION_NAMES: &[&str] = &[
80- "authors", "about", "api", "browse", "config", "rss", "static",
81- ];
82+ const BANNED_COLLECTION_NAMES: &[&str] = &["about", "api", "browse", "config", "rss", "static"];
83
84 const DEFAULT_ROBOTS_TXT: &str = r#"
85 User-agent: *
86 @@ -60,6 +59,16 @@ const DEFAULT_TREE_SITTER_KEYWORDS: &[&str] = &[
87 "removal",
88 ];
89
90+ #[derive(thiserror::Error, Debug)]
91+ pub enum Error {
92+ #[error("Nix: {0}")]
93+ Nix(#[from] nix::Error),
94+ #[error("User {0} was not found")]
95+ UserNotFound(String),
96+ #[error("User {0} has invalid configuration")]
97+ InvalidConfig(String),
98+ }
99+
100 fn default_false() -> bool {
101 false
102 }
103 @@ -227,27 +236,106 @@ pub struct Lfs {
104 pub url_template: String,
105 }
106
107- #[derive(Deserialize, Serialize, Clone, Debug, Default)]
108- pub struct Link {
109- pub rel: String,
110- pub href: Option<String>,
111- pub template: Option<String>,
112- pub mime_template: Option<String>,
113- }
114+ pub mod identity {
115+ use super::*;
116
117- #[derive(Deserialize, Serialize, Clone, Debug, Default)]
118- pub struct UrlLink {
119- pub url: String,
120- pub mime_type: Option<String>,
121- }
122+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
123+ pub struct Link {
124+ pub rel: String,
125+ pub href: Option<String>,
126+ pub template: Option<String>,
127+ pub mime_template: Option<String>,
128+ }
129
130- #[derive(Deserialize, Serialize, Clone, Debug, Default)]
131- pub struct Author {
132- pub email: String,
133- pub tagline: Option<String>,
134- pub avatar: Option<UrlLink>,
135- pub profiles: Option<Vec<UrlLink>>,
136- pub keys: Option<Vec<PublicKey>>,
137+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
138+ pub struct UrlLink {
139+ pub url: String,
140+ pub mime_type: Option<String>,
141+ }
142+
143+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
144+ pub struct Profile {
145+ pub email: String,
146+ pub tagline: Option<String>,
147+ pub avatar: Option<UrlLink>,
148+ pub profiles: Option<Vec<UrlLink>>,
149+ pub keys: Option<Vec<PublicKey>>,
150+ }
151+
152+ /// An identity that has been verified and loaded from the host
153+ #[derive(Clone, Debug)]
154+ #[allow(dead_code)]
155+ pub struct Resolved {
156+ // user configuration in ~/.config/ayllu/config.toml (me)
157+ pub profile: Profile,
158+ pub collections: Vec<Collection>,
159+ // system user account
160+ pub user: nix::unistd::User,
161+ // fully qualified repositories path
162+ pub repositories: PathBuf,
163+ }
164+
165+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
166+ pub struct Identity {
167+ pub username: String,
168+ #[serde(default = "Identity::default_repositories_path")]
169+ pub repositories_path: PathBuf,
170+ }
171+
172+ impl Identity {
173+ fn default_repositories_path() -> PathBuf {
174+ Path::new("repos").to_path_buf()
175+ }
176+ }
177+
178+ /// Configuration exposed to users
179+ #[derive(Deserialize, Serialize, Clone, Debug)]
180+ pub struct Config {
181+ #[serde(rename = "me")]
182+ pub self_: Option<Profile>,
183+ #[serde(default = "Vec::new")]
184+ pub collections: Vec<Collection>,
185+ }
186+
187+ impl Configurable for Config {
188+ fn validate(&mut self) -> Result<(), Box<dyn StdError>> {
189+ Ok(())
190+ }
191+ }
192+
193+ pub(crate) fn resolve(identity: &Identity) -> Result<Resolved, Error> {
194+ let name = &identity.username;
195+ match nix::unistd::User::from_name(&identity.username)? {
196+ Some(user) => {
197+ match Reader::<Config>::load(Some(
198+ user.dir.join(".config/ayllu/config.toml").as_path(),
199+ )) {
200+ Ok(user_config) => {
201+ match user_config.self_ {
202+ Some(profile) => Ok(Resolved {
203+ profile,
204+ collections: user_config.collections,
205+ repositories: user.dir.join(&identity.repositories_path),
206+ user,
207+ }),
208+ None => {
209+ // If the user did not specify their identity skip
210+ // them entirely.
211+ eprintln!("User {name} has no identity configuration, skipping",);
212+ Err(Error::InvalidConfig(name.to_string()))
213+ }
214+ }
215+ }
216+ Err(err) => {
217+ // invalid user config will just be ignored
218+ eprintln!("Failed to load user {name} configuration {err}",);
219+ Err(Error::InvalidConfig(name.to_string()))
220+ }
221+ }
222+ }
223+ None => Err(Error::UserNotFound(name.to_string())),
224+ }
225+ }
226 }
227
228 #[derive(Deserialize, Serialize, Clone, Debug)]
229 @@ -307,11 +395,13 @@ pub struct Config {
230 pub tree_sitter: Option<TreeSitter>,
231 pub languages: Option<Languages>,
232 pub lfs: Option<Lfs>,
233- pub authors: Option<Vec<Author>>,
234+ pub identities: Option<Vec<identity::Identity>>,
235+ #[serde(skip)]
236+ profiles: Vec<identity::Profile>,
237 }
238
239 impl Configurable for Config {
240- fn validate(&mut self) -> Result<(), Box<dyn Error>> {
241+ fn validate(&mut self) -> Result<(), Box<dyn StdError>> {
242 let parsed_url = Url::parse(&self.origin)?;
243 self.domain = parsed_url.domain().map(|domain| domain.to_string());
244
245 @@ -346,11 +436,26 @@ impl Configurable for Config {
246 );
247 }
248
249+ // resolve user collections
250+ if let Some(identities) = &self.identities {
251+ self.collections.extend(identities.iter().try_fold(
252+ Vec::new(),
253+ |mut accm, ident| {
254+ let resolved = identity::resolve(ident)?;
255+ accm.extend(resolved.collections);
256+ Ok::<Vec<Collection>, Error>(accm)
257+ },
258+ )?);
259+ }
260 Ok(())
261 }
262 }
263
264 impl Config {
265+ pub fn profiles(&self) -> &[identity::Profile] {
266+ self.profiles.as_slice()
267+ }
268+
269 fn default_robots_txt() -> String {
270 String::from(DEFAULT_ROBOTS_TXT.trim_start())
271 }
272 diff --git a/ayllu/src/web2/middleware/sites.rs b/ayllu/src/web2/middleware/sites.rs
273index 664befe..65e1459 100644
274--- a/ayllu/src/web2/middleware/sites.rs
275+++ b/ayllu/src/web2/middleware/sites.rs
276 @@ -20,7 +20,7 @@ pub type State = extract::State<(Config, Vec<(String, (String, String))>)>;
277 pub type Sites = Vec<(String, (String, String))>;
278
279 // array of all the repositories in each collection
280- fn repositories(collections: Vec<Collection>) -> Result<Vec<PathBuf>, Error> {
281+ fn repositories(collections: &[Collection]) -> Result<Vec<PathBuf>, Error> {
282 let mut paths: Vec<PathBuf> = Vec::new();
283 for collection in collections.iter() {
284 let collection_path = Path::new(&collection.path);
285 @@ -37,7 +37,7 @@ fn repositories(collections: Vec<Collection>) -> Result<Vec<PathBuf>, Error> {
286
287 pub fn sites(cfg: &Config) -> Result<Sites, Error> {
288 let mut sites: Vec<(String, (String, String))> = Vec::new();
289- for path in repositories(cfg.collections.clone())? {
290+ for path in repositories(&cfg.collections)? {
291 let repository = Repository::new(path.as_path())?;
292 let repo_config = repository.config()?;
293 if repo_config.sites.header.is_some() {
294 diff --git a/ayllu/src/web2/routes/finger.rs b/ayllu/src/web2/routes/finger.rs
295index 39674bb..6e79724 100644
296--- a/ayllu/src/web2/routes/finger.rs
297+++ b/ayllu/src/web2/routes/finger.rs
298 @@ -6,7 +6,7 @@ use url::Url;
299 use webfinger_rs::{Link, WebFingerRequest, WebFingerResponse};
300
301 use crate::{
302- config::{Author, Collection, Config},
303+ config::{identity::Profile, Collection, Config},
304 web2::error::Error,
305 };
306 use ayllu_git::{name, Error as GitError, Wrapper as Repository};
307 @@ -47,7 +47,7 @@ fn get_all(collections: &[Collection]) -> Result<Vec<Pair>, ayllu_git::Error> {
308
309 #[derive(Clone, Debug)]
310 enum Resource {
311- Acct(Author),
312+ Acct(Profile),
313 Collection((Collection, Vec<String>)),
314 Repository((Collection, PathBuf)),
315 Index,
316 @@ -58,25 +58,22 @@ enum Resource {
317 #[derive(Clone)]
318 pub struct Resolver {
319 pub collections: Vec<Collection>,
320- pub authors: HashMap<String, Author>,
321+ pub identities: HashMap<String, Profile>,
322 origin: Url,
323 }
324
325 impl Resolver {
326 pub fn new(config: &Config) -> Self {
327- let authors = if let Some(authors) = config.authors.as_ref() {
328- HashMap::from_iter(
329- authors
330- .iter()
331- .map(|author| (author.email.clone(), author.clone())),
332- )
333- } else {
334- HashMap::default()
335- };
336+ let identities = HashMap::from_iter(
337+ config
338+ .profiles()
339+ .iter()
340+ .map(|profile| (profile.email.clone(), profile.clone())),
341+ );
342 Resolver {
343 origin: Url::parse(&config.origin).expect("Origin URL is invalid"),
344 collections: config.collections.clone(),
345- authors,
346+ identities,
347 }
348 }
349
350 @@ -92,7 +89,7 @@ impl Resolver {
351 None => None,
352 };
353 if let Some(account) = account {
354- if let Some(author) = self.authors.get(account) {
355+ if let Some(author) = self.identities.get(account) {
356 return Ok(Resource::Acct(author.clone()));
357 } else {
358 return Err::<Resource, Error>(Error::NotFound(resource.to_string()));
359 diff --git a/ayllu/src/web2/routes/rest/discovery.rs b/ayllu/src/web2/routes/rest/discovery.rs
360index 2bf6dd4..f825d04 100644
361--- a/ayllu/src/web2/routes/rest/discovery.rs
362+++ b/ayllu/src/web2/routes/rest/discovery.rs
363 @@ -8,7 +8,7 @@ use ayllu_git::{Scanner, Wrapper};
364
365 pub async fn serve(Extension(cfg): Extension<Config>) -> Result<Json<Vec<Collection>>, Error> {
366 let mut collections: Vec<Collection> = Vec::new();
367- for config in cfg.collections {
368+ for config in cfg.collections.iter() {
369 if config.hidden.is_some_and(|hidden| hidden) {
370 continue;
371 }
372 diff --git a/ayllu/src/web2/routes/rss.rs b/ayllu/src/web2/routes/rss.rs
373index 98891e2..f742768 100644
374--- a/ayllu/src/web2/routes/rss.rs
375+++ b/ayllu/src/web2/routes/rss.rs
376 @@ -123,7 +123,7 @@ struct Builder {
377 impl Builder {
378 fn scan_repositories(
379 &self,
380- collections: Vec<Collection>,
381+ collections: &[Collection],
382 ) -> Result<Vec<(PathBuf, String)>, Error> {
383 let mut entries: Vec<(PathBuf, String)> = Vec::new();
384 for collection in collections.iter() {
385 @@ -288,13 +288,13 @@ impl Builder {
386
387 pub async fn feed_firehose(Extension(cfg): Extension<Config>) -> Result<Response, Error> {
388 let builder = Builder {
389- origin: cfg.origin,
390+ origin: cfg.origin.clone(),
391 title: String::from("Firehose"),
392 time_to_live: cfg.rss_time_to_live.map(Duration::seconds),
393 current_time: OffsetDateTime::now_utc(),
394 };
395 let channel = builder.firehose(
396- builder.scan_repositories(cfg.collections)?,
397+ builder.scan_repositories(&cfg.collections)?,
398 Duration::days(7),
399 )?;
400 let response = Response::builder()
401 @@ -306,13 +306,13 @@ pub async fn feed_firehose(Extension(cfg): Extension<Config>) -> Result<Response
402
403 pub async fn feed_1d(Extension(cfg): Extension<Config>) -> Result<Response, Error> {
404 let builder = Builder {
405- origin: cfg.origin,
406+ origin: cfg.origin.clone(),
407 title: String::from("Daily Update Summary"),
408 time_to_live: cfg.rss_time_to_live.map(Duration::seconds),
409 current_time: OffsetDateTime::now_utc(),
410 };
411 let channel = builder.summary(
412- builder.scan_repositories(cfg.collections)?,
413+ builder.scan_repositories(&cfg.collections)?,
414 Timeframe::Daily,
415 )?;
416 let response = Response::builder()
417 @@ -324,13 +324,13 @@ pub async fn feed_1d(Extension(cfg): Extension<Config>) -> Result<Response, Erro
418
419 pub async fn feed_1w(Extension(cfg): Extension<Config>) -> Result<Response, Error> {
420 let builder = Builder {
421- origin: cfg.origin,
422+ origin: cfg.origin.clone(),
423 title: String::from("Weekly Update Summary"),
424 time_to_live: cfg.rss_time_to_live.map(Duration::seconds),
425 current_time: OffsetDateTime::now_utc(),
426 };
427 let channel = builder.summary(
428- builder.scan_repositories(cfg.collections)?,
429+ builder.scan_repositories(&cfg.collections)?,
430 Timeframe::Weekly,
431 )?;
432 let response = Response::builder()
433 @@ -342,13 +342,13 @@ pub async fn feed_1w(Extension(cfg): Extension<Config>) -> Result<Response, Erro
434
435 pub async fn feed_1m(Extension(cfg): Extension<Config>) -> Result<Response, Error> {
436 let builder = Builder {
437- origin: cfg.origin,
438+ origin: cfg.origin.clone(),
439 title: String::from("Monthly Update Summary"),
440 time_to_live: cfg.rss_time_to_live.map(Duration::seconds),
441 current_time: OffsetDateTime::now_utc(),
442 };
443 let channel = builder.summary(
444- builder.scan_repositories(cfg.collections)?,
445+ builder.scan_repositories(&cfg.collections)?,
446 Timeframe::Monthly,
447 )?;
448 let response = Response::builder()
449 diff --git a/config.example.toml b/config.example.toml
450index df6ede3..146046f 100644
451--- a/config.example.toml
452+++ b/config.example.toml
453 @@ -70,19 +70,63 @@ export_all = false
454 # job for every commit in the repository.
455 timeout = 1800
456
457+ ##
458+ ## Identities
459+ ##
460+
461+ # Users are associated with "Identities" in Ayllu where each identity refers
462+ # to a particular code author and system account on a given server. If the
463+ # Ayllu HTTP server configured with [[ identities ]] then the deligated account
464+ # may maintain it's own configuration and collection of repositories.
465+
466+ # [[identities]]
467+ # username = "demo"
468+ # # optional, relative to the user's home directory
469+ # repositories_path = "repos"
470+
471+ # A deligated account may also manage their own identity information by
472+ # creating another Ayllu configuration file at ~/.config/ayllu/config.toml.
473+ # A deligated account may not further deligate other identities and must
474+ # include a "me" section in their configuration.
475+ # [me]
476+ # # E-mail address of the author
477+ # email = "example@example.org"
478+ # # Optional "tagline" associated with the author
479+ # tagline = "Programmer interested free software"
480+ # # Optional link to an avatar containing an image representing the author
481+ # avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" }
482+ # # Array of personal websites, social media, etc. associated with the author
483+ # profiles = [
484+ # { url = "https://example.com", mime_type = "text/html"},
485+ # { url = "https://example.org/@example", mime_type = "text/html"},
486+ # ]
487+
488+ # Deligated accounts may specify their own collections but they must be
489+ # relative to the global identities.repositories_path set by the site
490+ # administrator. Note that the repository path MUST be at least readable by
491+ # the system ayllu account such that the web server can serve the repository
492+ # contents.
493+ # [[ collection ]]
494+ # name = "my-code"
495+ # description = "random software"
496+ # path = "./projects/fuu"
497+
498+ # A configuration that specifies both [[ identities ]] and [ me ] is considered
499+ # invalid and will prevent the server from starting up.
500+
501 # List of authors associated with this site
502- [[authors]]
503- # E-mail address of the author
504- email = "example@example.org"
505- # Optional "tagline" associated with the author
506- tagline = "Programmer interested free software"
507- # Optional link to an avatar containing an image representing the author
508- avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" }
509- # Array of personal websites, social media, etc. associated with the author
510- profiles = [
511- { url = "https://example.com", mime_type = "text/html"},
512- { url = "https://example.org/@example", mime_type = "text/html"},
513- ]
514+ # [[authors]]
515+ # # E-mail address of the author
516+ # email = "example@example.org"
517+ # # Optional "tagline" associated with the author
518+ # tagline = "Programmer interested free software"
519+ # # Optional link to an avatar containing an image representing the author
520+ # avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" }
521+ # # Array of personal websites, social media, etc. associated with the author
522+ # profiles = [
523+ # { url = "https://example.com", mime_type = "text/html"},
524+ # { url = "https://example.org/@example", mime_type = "text/html"},
525+ # ]
526
527 [http]
528 # interface and port to listen on