server/lib/src/account.rs
//! **The repositories a person has here** (`holger.account.v1`, 2026-10-21, lane P4).
//!
//! The console is a thin layer. Its "My repositories" page lists what THIS server says the
//! signed-in person has, makes a private repository through THIS server, and shows the client
//! setup THIS server writes. The console keeps no owners, no permissions and no store.
//!
//! * **Who has what.** A repository made at run time carries its owner in the record the twin
//! carries and a recharge brings back (`fleet/sold-repositories.json`, [`crate::sold`]). The
//! owner sees it; an ADMIN of this server sees every repository but the estate's system ones.
//! * **Making one.** An admin makes a PRIVATE repository in a format this server already serves
//! writable: it is shaped like that repository (its door, its storage, its own bytes beside
//! it), its reads need a credential from the first request ([`crate::auth::ReadGate::
//! gate_from_now`]), and it is served at once ([`crate::live_routes::LiveRoutes::serve_as`]).
//! Private here means OWNER-ONLY (lane P1): its owner, the people its owner granted
//! ([`Grant`](pb::account_service_server::AccountService::grant)) and the admins.
//! * **Client setup.** One snippet per client, with `YOUR-TOKEN` where a token from the
//! console's Tokens page goes. The address is `HOLGER_REPOSITORY_URL` when set, else the
//! console's own origin (the passkey relying party's), else a placeholder that says so.
use std::sync::Arc;
use tonic::{Request, Response, Status};
use crate::audit::{AuditAction, AuditEvent};
use crate::grpc::holger_account_proto as pb;
use crate::grpc::{coded, internal_err, HolgerGrpc};
use holger_errcode as ec;
/// The environment variable that names the address package clients use for this server's
/// repositories (`https://repo.example.org`). Unset: the console's own origin.
pub const REPOSITORY_URL_ENV: &str = "HOLGER_REPOSITORY_URL";
/// Where a token from the Tokens page goes in every snippet.
pub const TOKEN_PLACEHOLDER: &str = "YOUR-TOKEN";
/// The address used when this server knows none.
pub const UNKNOWN_ADDRESS: &str = "https://THIS-HOLGER";
/// What the console shows to a caller who is not signed in.
pub const SIGN_IN: &str = "sign in to see your repositories";
/// What the console shows to an admin.
pub const ADMIN_MAKES: &str =
"you are an admin of this server: you see every repository and you may make a private one";
/// What the console shows to a person who is no admin.
pub const AN_ADMIN_MAKES: &str =
"these are the repositories made for you; an admin of this server makes new ones";
/// What the console shows when this server makes no repository at run time.
pub const MAKES_NONE: &str =
"this server makes no repository at run time; its repositories are set by its operator";
/// Pure: the client's word for a server repository type.
pub fn format_of(repo_type: &str) -> String {
let repo_type = repo_type.trim().to_lowercase();
match repo_type.as_str() {
"rust" => "cargo",
"maven3" | "maven" => "maven",
"pip" => "pip",
"npm" => "npm",
"raw" => "raw",
"oci" | "docker" => "oci",
_ => return repo_type,
}
.to_owned()
}
/// Pure: **the client setup for repository `name` of `format` at `base`** (no trailing `/`).
/// `key` is the box's repository key (HOL-SIGN-2): the deb and rpm setup name its
/// fingerprint as the one key the client trusts, or say that the box holds none yet.
pub fn clients_for(
name: &str,
format: &str,
base: &str,
key: Option<&crate::repo_sign::RepoKey>,
) -> Vec<pb::ClientSetup> {
let base = base.trim().trim_end_matches('/');
let host = base.split("://").nth(1).unwrap_or(base);
let t = TOKEN_PLACEHOLDER;
let setup = |client: &str, file: &str, snippet: String| pb::ClientSetup {
client: client.into(),
file: file.into(),
snippet,
};
// HOL-SIGN-2: which key a client must trust: THIS box's, by fingerprint. A downstream
// signs with its own key, so its console names its own fingerprint, never its upstream's.
let trust = match key {
Some(k) => format!(
"# The key to trust: this Holger's repository key, fingerprint {} ({}).\n",
k.fingerprint_hex(),
k.user_id()
),
None => format!(
"# This Holger holds no repository key yet, so the client cannot verify its metadata; \
its admin makes one with `holger repo-key create --config <ron>` ({}).\n",
crate::repo_sign::KEY_PATH
),
};
match format {
"cargo" => vec![
setup("cargo", "~/.cargo/config.toml", format!("[registries.{name}]\nindex = \"sparse+{base}/{name}/index/\"\n")),
setup("cargo", "~/.cargo/credentials.toml", format!("[registries.{name}]\ntoken = \"{t}\"\n")),
setup("cargo", "shell", format!("cargo publish --registry {name}\n")),
],
"pip" => vec![
setup("pip", "~/.config/pip/pip.conf", format!("[global]\nindex-url = https://token:{t}@{host}/{name}/simple/\n")),
setup("pip", "shell", format!("pip install --index-url https://token:{t}@{host}/{name}/simple/ <package>\n")),
],
"npm" => vec![setup("npm", ".npmrc", format!("registry={base}/{name}/\n//{host}/{name}/:_authToken={t}\n"))],
"maven" => vec![setup(
"maven",
"~/.m2/settings.xml",
format!(
"<settings>\n <servers>\n <server>\n <id>{name}</id>\n <username>token</username>\n <password>{t}</password>\n </server>\n </servers>\n <mirrors>\n <mirror>\n <id>{name}</id>\n <mirrorOf>*</mirrorOf>\n <url>{base}/{name}/</url>\n </mirror>\n </mirrors>\n</settings>\n"
),
)],
// HOL-SIGNED-APT-DNF: the views sign their metadata, and the client checks it with the
// key `/-/repo-key.asc` serves: no `[trusted=yes]`, no `gpgcheck=0`.
"deb" => vec![
setup("apt", "shell", format!("sudo install -d -m 0755 /etc/apt/keyrings\ncurl -fsSL {base}{key} | sudo tee /etc/apt/keyrings/{name}.asc >/dev/null\n{trust}# Check that the file holds that key before apt trusts it:\ngpg --show-keys /etc/apt/keyrings/{name}.asc\n", key = crate::repo_sign::KEY_PATH)),
setup("apt", &format!("/etc/apt/sources.list.d/{name}.list"), format!("deb [signed-by=/etc/apt/keyrings/{name}.asc] {base}/{name} stable main\n")),
setup("apt", &format!("/etc/apt/auth.conf.d/{name}.conf"), format!("machine {host}/{name}\nlogin token\npassword {t}\n")),
],
"rpm" => vec![setup(
"dnf",
&format!("/etc/yum.repos.d/{name}.repo"),
format!(
"{trust}[{name}]\nname={name}\nbaseurl={base}/{name}\nusername=token\npassword={t}\nenabled=1\n# the packages' own signatures, against their vendor's key (rpm --import)\ngpgcheck=1\n# the metadata Holger signs, against Holger's key (dnf shows its fingerprint when it imports it)\nrepo_gpgcheck=1\ngpgkey={base}{key}\n",
key = crate::repo_sign::KEY_PATH
),
)],
"oci" => vec![setup("docker", "shell", format!("docker login {host} -u token -p {t}\ndocker push {host}/{name}/<image>:<tag>\n"))],
_ => vec![setup("curl", "shell", format!("curl -H \"Authorization: Bearer {t}\" -T <file> {base}/{name}/<path>\ncurl -H \"Authorization: Bearer {t}\" -O {base}/{name}/<path>\n"))],
}
}
/// Pure: **the client setup for a side door** (lane HOL-FIELD-2 P19): a repository outside
/// the intake chain on a Holger whose policy requires a scan refuses writes
/// (`holger-intake-15`), so its setup keeps the reads and drops every publish and upload;
/// a note says publishing goes through the intake door, into `incoming`, or for a crates
/// registry through the chain's cargo door when the chain has one (lane
/// HOL-CARGO-PUBLISH-DOOR).
pub fn clients_for_side_door(
name: &str,
format: &str,
base: &str,
incoming: &str,
cargo_door: Option<&str>,
key: Option<&crate::repo_sign::RepoKey>,
) -> Vec<pb::ClientSetup> {
let writes = |line: &str| {
line.starts_with("cargo publish")
|| line.contains(" -T ")
|| line.starts_with("docker push")
};
let mut out: Vec<pb::ClientSetup> = clients_for(name, format, base, key)
.into_iter()
.filter_map(|mut s| {
let kept: Vec<&str> = s.snippet.lines().filter(|l| !writes(l)).collect();
if kept.is_empty() {
return None;
}
s.snippet = kept.join("\n") + "\n";
Some(s)
})
.collect();
let snippet = match (format, cargo_door) {
("cargo", Some(door)) => format!(
"# '{name}' takes no writes: this server's policy requires a scan, and publishing goes \
through the intake chain. Publish to the chain's cargo door '{door}':\n\
cargo publish --registry {door}\n"
),
_ => format!(
"# '{name}' takes no writes: this server's policy requires a scan, and publishing goes \
through the intake door. Upload into '{incoming}' and walk it through the intake chain.\n"
),
};
out.push(pb::ClientSetup {
client: format.into(),
file: "publishing".into(),
snippet,
});
out
}
/// Pure: **the client setup for the chain's cargo door** (lane HOL-CARGO-PUBLISH-DOOR): the
/// registry, its token, `cargo publish`, `cargo yank` and `cargo install`, and a note that a
/// publish is served once it walked the intake chain (scanned, checked, approved).
pub fn clients_for_cargo_door(
name: &str,
base: &str,
key: Option<&crate::repo_sign::RepoKey>,
) -> Vec<pb::ClientSetup> {
let mut out = clients_for(name, "cargo", base, key);
if let Some(shell) = out.iter_mut().find(|s| s.file == "shell") {
shell.snippet = format!(
"cargo publish --registry {name}\n\
cargo install --registry {name} <crate>\n\
cargo yank --registry {name} <crate> --version <version>\n"
);
}
out.push(pb::ClientSetup {
client: "cargo".into(),
file: "publishing".into(),
snippet: format!(
"# '{name}' is this server's cargo door into the intake chain: `cargo publish` is \
INGESTED, and the crate is listed and installable once it is AVAILABLE (scanned, \
checked against the policy, approved). A crate with a malware finding, or one pinned \
to a dependency version with a known advisory, is refused by name.\n"
),
});
out
}
/// The address package clients use for this server's repositories.
fn repository_base(g: &HolgerGrpc) -> String {
if let Ok(url) = std::env::var(REPOSITORY_URL_ENV) {
if !url.trim().is_empty() {
return url.trim().trim_end_matches('/').to_owned();
}
}
g.auth_config
.methods
.iter()
.find_map(|m| match m {
// WWW-CANON: the site's own origin (`https://www.<rp_id>`), not the kept bare one.
crate::auth::AuthMethodConfig::Passkey {
rp_id, rp_origin, ..
} => Some(crate::auth::site_origin(rp_id, rp_origin)),
_ => None,
})
.unwrap_or_else(|| UNKNOWN_ADDRESS.to_owned())
}
/// The formats this server can make a repository in now: every format a served, writable,
/// non-system repository of the config has (its template), first such repository by name.
fn templates(g: &HolgerGrpc) -> Vec<(String, String)> {
let Some(live) = &g.live_routes else {
return Vec::new();
};
let made = g
.data_root
.as_deref()
.map(crate::sold::read)
.unwrap_or_default();
let mut served = g.routes.all_repos();
served.sort_by(|a, b| a.0.cmp(&b.0));
let mut out: Vec<(String, String)> = Vec::new();
for (name, b) in served {
let info = traits::RepositoryInfo::of(&name, b.as_ref());
if info.is_group
|| !info.writable
|| g.read_gate.is_system(&name)
|| made.contains_key(&name)
|| !live.declares(&name)
{
continue;
}
let format = format_of(&info.repo_type);
if !out.iter().any(|(f, _)| *f == format) {
out.push((format, name));
}
}
out
}
/// **A person this server knows**, by a stable id: their subject, or an e-mail exactly one
/// active person holds: `(subject, name)`. Never by their display name (RA5-7): names are not
/// unique (a stranger may sign up as "Kim" before Kim), so a grant to a name could reach
/// somebody else. People are the identity store's (`holger-passkeys/identity.json`).
fn person_named(data_root: &std::path::Path, person: &str) -> Option<(String, String)> {
let person = person.trim();
if person.is_empty() {
return None;
}
#[cfg(feature = "auth-passkey")]
{
let people = crate::identity_host::people(data_root);
let active: Vec<&nornir_identity::User> = people
.iter()
.filter(|u| u.state == nornir_identity::UserState::Active)
.collect();
if let Some(hit) = active.iter().find(|u| u.id == person) {
return Some((hit.id.clone(), hit.display.clone()));
}
let mail = person.to_ascii_lowercase();
let held: Vec<&&nornir_identity::User> = active
.iter()
.filter(|u| {
u.email
.as_deref()
.is_some_and(|e| e.trim().eq_ignore_ascii_case(&mail))
})
.collect();
match held.as_slice() {
[one] => Some((one.id.clone(), one.display.clone())),
_ => None,
}
}
#[cfg(not(feature = "auth-passkey"))]
{
let _ = data_root;
None
}
}
/// The grants of `m`, named as the passkey store names their people.
fn grants_of(g: &HolgerGrpc, m: &crate::sold::SoldRepository) -> Vec<pb::RepositoryGrant> {
m.grants
.iter()
.map(|(subject, access)| {
let person = g
.data_root
.as_deref()
.and_then(|r| person_named(r, subject))
.map(|(_, n)| n)
.unwrap_or_else(|| subject.clone());
pb::RepositoryGrant {
person,
subject: subject.clone(),
access: access.clone(),
}
})
.collect()
}
impl HolgerGrpc {
/// Is the caller an admin of this server? (No RBAC: every caller is, as every gate says.)
fn is_admin_here(&self, identity: &Option<crate::auth::AuthIdentity>) -> bool {
if !self.auth_config.rbac_enabled() {
return true;
}
identity.as_ref().is_some_and(|id| {
self.auth_config
.role_for_identity_in_repo("", id)
.can_admin()
})
}
/// One row of "My repositories".
/// The client setup of `name`: a side door's reads only (P19, a chain build), else the
/// whole setup.
fn clients_of(&self, name: &str, format: &str, base: &str) -> Vec<pb::ClientSetup> {
#[cfg(feature = "intake")]
if let Some(c) = self.intake.as_ref() {
// HOL-CARGO-PUBLISH-DOOR: the chain's cargo door offers publish, install and yank.
if c.cargo_door() == Some(name) {
return clients_for_cargo_door(name, base, self.repo_key.as_deref());
}
if c.is_side_door(name) {
return clients_for_side_door(
name,
format,
base,
&c.settings().incoming,
c.cargo_door(),
self.repo_key.as_deref(),
);
}
}
clients_for(name, format, base, self.repo_key.as_deref())
}
fn my_row(
&self,
name: &str,
made: Option<&crate::sold::SoldRepository>,
base: &str,
) -> pb::MyRepository {
let served = self.routes.lookup(name);
let repo_type = match &served {
Some(b) => traits::RepositoryInfo::of(name, b.as_ref()).repo_type,
None => made
.and_then(|m| self.routes.lookup(&m.template))
.map(|b| traits::RepositoryInfo::of(name, b.as_ref()).repo_type)
.unwrap_or_default(),
};
let format = format_of(&repo_type);
let private = self.read_gate.repo_requires_auth(name) || made.is_some_and(|m| m.private);
let words = match (&served, made) {
(None, _) => "made, and served from this server's next start".to_owned(),
(Some(_), _) if private => {
"private: every read needs a credential of this server".to_owned()
}
(Some(_), _) => "open: anyone who reaches this server may read it".to_owned(),
};
let (plan, plan_state, paid_until_unix_ms, plan_words) = self.plan_of(name);
pb::MyRepository {
name: name.to_owned(),
clients: self.clients_of(name, &format, base),
format,
private,
owner: made.map(|m| m.owner.clone()).unwrap_or_default(),
grants: made.map(|m| grants_of(self, m)).unwrap_or_default(),
served: served.is_some(),
words,
plan,
plan_state,
paid_until_unix_ms,
plan_words,
}
}
/// **Where its plan stands**: the stored fact's plan, state and paid-until, by this
/// server's own clock (a lapsed period is said as such). No fact: free.
#[cfg(feature = "metered")]
fn plan_of(&self, name: &str) -> (String, String, i64, String) {
use monetize_product::State;
let fact = self
.entitlements
.as_ref()
.and_then(|s| s.get(name).ok().flatten());
let Some(f) = fact else { return free() };
let until = f.paid_until_unix_ms.unwrap_or(0) as i64;
let date = if until > 0 {
crate::metered::civil_date(until as u64)
} else {
String::new()
};
if let Some(why) = crate::metered::lapsed(&f, crate::clock::now_unix_ms()) {
return (f.plan.clone(), "lapsed".into(), until, why);
}
let (state, words) = match f.state {
State::Free => return free(),
State::Paid if date.is_empty() => ("paid", "paid".to_owned()),
State::Paid => ("paid", format!("paid until {date}")),
State::Grace => (
"grace",
format!(
"in its grace period: pay before it is suspended{}",
if date.is_empty() {
String::new()
} else {
format!(" (paid until {date})")
}
),
),
State::Suspended => (
"suspended",
"suspended: uploads are refused until it is paid again".to_owned(),
),
State::Retention => (
"retention",
"kept for retention only: uploads are refused".to_owned(),
),
};
(f.plan.clone(), state.into(), until, words)
}
#[cfg(not(feature = "metered"))]
fn plan_of(&self, _name: &str) -> (String, String, i64, String) {
free()
}
}
#[tonic::async_trait]
impl pb::account_service_server::AccountService for Arc<HolgerGrpc> {
async fn my_repositories(
&self,
request: Request<pb::MyRepositoriesRequest>,
) -> Result<Response<pb::MyRepositoriesReply>, Status> {
// Never refused: an invalid credential is a caller with nothing here.
let identity = self
.authenticate(&request, "list your repositories")
.await
.unwrap_or(None);
if self.auth_config.rbac_enabled() && identity.is_none() {
return Ok(Response::new(pb::MyRepositoriesReply {
repositories: Vec::new(),
formats: Vec::new(),
words: SIGN_IN.to_owned(),
}));
}
let admin = self.is_admin_here(&identity);
let subject = identity
.as_ref()
.map(|i| i.subject.clone())
.unwrap_or_default();
let made = self
.data_root
.as_deref()
.map(crate::sold::read)
.unwrap_or_default();
let base = repository_base(self);
let mut names: Vec<String> = Vec::new();
if admin {
names.extend(
self.routes
.all_repos()
.into_iter()
.map(|(n, _)| n)
.filter(|n| !self.read_gate.is_system(n)),
);
}
names.extend(
made.iter()
.filter(|(_, m)| {
admin
|| (!subject.is_empty()
&& (m.owner == subject || m.grants.contains_key(&subject)))
})
.map(|(n, _)| n.clone()),
);
names.sort();
names.dedup();
let repositories = names
.iter()
.map(|n| self.my_row(n, made.get(n), &base))
.collect();
let formats: Vec<String> = if admin {
templates(self).into_iter().map(|(f, _)| f).collect()
} else {
Vec::new()
};
let words = match (admin, formats.is_empty()) {
(true, true) => MAKES_NONE,
(true, false) => ADMIN_MAKES,
(false, _) => AN_ADMIN_MAKES,
};
Ok(Response::new(pb::MyRepositoriesReply {
repositories,
formats,
words: words.to_owned(),
}))
}
/// **The owner grants or revokes** (lane P1): one named person, one owner-only repository,
/// `read`, `write` (read and write) or `none`. Its owner or an admin only; audited; the gate
/// every door holds learns it at once and the record the twin carries keeps it.
async fn grant(
&self,
request: Request<pb::GrantRequest>,
) -> Result<Response<pb::GrantReply>, Status> {
self.mutation_hold()?;
let identity = self.authenticate(&request, "grant on a repository").await?;
let Some(caller) = identity.clone() else {
return Err(coded(
&ec::ACCOUNT_GRANT_NOT_OWNER,
tonic::Code::Unauthenticated,
"sign in: only the repository's owner or an admin grants on it",
));
};
let r = request.into_inner();
let repo = r.repository.trim().to_owned();
let write = match r.access.trim().to_ascii_lowercase().as_str() {
"read" => Some(false),
"write" | "read+write" | "readwrite" => Some(true),
"none" | "revoke" | "" => None,
other => {
return Err(coded(
&ec::ACCOUNT_GRANT_ACCESS,
tonic::Code::InvalidArgument,
format!("{other:?} is no access: read, write or none"),
))
}
};
let root = self.data_root.clone().ok_or_else(|| {
coded(
&ec::ACCOUNT_MAKES_NONE,
tonic::Code::FailedPrecondition,
MAKES_NONE,
)
})?;
let admin = self.is_admin_here(&identity);
let unknown = || {
coded(&ec::ACCOUNT_GRANT_UNKNOWN, tonic::Code::NotFound, format!("'{repo}' is no owner-only repository here, or {:?} is nobody here (name the person by their id or e-mail, never their display name)", r.person.trim()))
};
let made = crate::sold::read(&root);
let Some(m) = made
.get(&repo)
.filter(|_| self.read_gate.sold_owner(&repo).is_some())
else {
return Err(unknown());
};
if !admin && (m.owner.is_empty() || m.owner != caller.subject) {
return Err(coded(
&ec::ACCOUNT_GRANT_NOT_OWNER,
tonic::Code::PermissionDenied,
format!("'{repo}' is not yours: only its owner or an admin grants on it"),
));
}
let (subject, person) = person_named(&root, r.person.trim()).ok_or_else(unknown)?;
if subject == m.owner {
return Err(coded(
&ec::ACCOUNT_GRANT_UNKNOWN,
tonic::Code::InvalidArgument,
format!("{person} owns '{repo}': an owner needs no grant"),
));
}
let updated = crate::sold::update(&root, |made| {
let s = made.get_mut(&repo).ok_or_else(|| "gone".to_owned())?;
match write {
Some(w) => {
s.grants
.insert(subject.clone(), crate::sold::access_word(w).to_owned());
}
None => {
s.grants.remove(&subject);
}
}
Ok(s.clone())
})
.map_err(|_| unknown())?;
self.read_gate.grant(&repo, &subject, write);
let said = match write {
Some(w) => format!(
"{person} may {} '{repo}'",
if w { "read and write" } else { "read" }
),
None => format!("{person} no longer has '{repo}'"),
};
self.record_audit(
AuditEvent::new(
caller.audit_name(),
AuditAction::Other,
&repo,
"",
"account",
200,
0,
)
.with_detail(format!("grant: {said} ({subject})")),
);
Ok(Response::new(pb::GrantReply {
repository: repo,
grants: grants_of(self, &updated),
words: said,
}))
}
async fn create_repository(
&self,
request: Request<pb::CreateRepositoryRequest>,
) -> Result<Response<pb::CreateRepositoryReply>, Status> {
self.mutation_hold()?;
let identity = self
.authorize_admin_identity(&request, "", "make a repository")
.await?;
let owner = identity
.as_ref()
.map(|i| i.subject.clone())
.unwrap_or_default();
let name = request.get_ref().name.trim().to_owned();
let format = request.get_ref().format.trim().to_lowercase();
if !crate::sold::repository_name_ok(&name) {
return Err(coded(&ec::ACCOUNT_NAME_REFUSED, tonic::Code::InvalidArgument, format!("{name:?} is no repository name (lower-case letters, digits, '-', '_' and '.', at most 64): nothing was made")));
}
let (Some(root), Some(live)) = (self.data_root.clone(), self.live_routes.clone()) else {
return Err(coded(
&ec::ACCOUNT_MAKES_NONE,
tonic::Code::FailedPrecondition,
MAKES_NONE,
));
};
let offered = templates(self);
let Some((_, template)) = offered.iter().find(|(f, _)| *f == format).cloned() else {
let can: Vec<&str> = offered.iter().map(|(f, _)| f.as_str()).collect();
return Err(coded(
&ec::ACCOUNT_FORMAT_REFUSED,
tonic::Code::FailedPrecondition,
format!(
"this server makes no {format:?} repository (it makes: {})",
if can.is_empty() {
"none".to_owned()
} else {
can.join(", ")
}
),
));
};
if self.routes.lookup(&name).is_some() {
return Err(coded(
&ec::ACCOUNT_NAME_TAKEN,
tonic::Code::AlreadyExists,
format!("'{name}' is taken: this server serves a repository by that name"),
));
}
crate::sold::update(&root, |made| {
if made.contains_key(&name) {
return Err(format!(
"'{name}' is taken: a repository by that name was made here"
));
}
made.insert(
name.clone(),
crate::sold::SoldRepository {
template: template.clone(),
signup: String::new(),
owner: owner.clone(),
private: true,
..Default::default()
},
);
Ok(())
})
.map_err(|e| {
if e.contains("is taken") {
coded(&ec::ACCOUNT_NAME_TAKEN, tonic::Code::AlreadyExists, e)
} else {
internal_err(e)
}
})?;
self.record_audit(
AuditEvent::new(
if owner.is_empty() {
"anonymous"
} else {
&owner
},
AuditAction::Other,
&name,
"",
"account",
200,
0,
)
.with_detail(format!(
"made private, owned by {owner:?}, shaped like {template}"
)),
);
// Owner-only (lane P1): its owner, the people the owner granted and the admins.
live.own(&name, &crate::sold::gate_owner(&root, &owner));
let words = match live.serve_as(&name, &template, true) {
Ok(()) => format!("'{name}' is made and served: private, and yours"),
Err(why) => {
log::warn!("repository '{name}': made private for {owner:?}, served from the next start ({why})");
format!("'{name}' is made, and served from this server's next start ({why})")
}
};
let made = crate::sold::read(&root);
let repository = self.my_row(&name, made.get(&name), &repository_base(self));
Ok(Response::new(pb::CreateRepositoryReply {
repository: Some(repository),
words,
}))
}
}
/// A repository with no stored fact: nothing limits it.
fn free() -> (String, String, i64, String) {
(
String::new(),
"free".into(),
0,
"nothing limits it here".into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_client_words_name_the_server_types() {
assert_eq!(format_of("rust"), "cargo");
assert_eq!(format_of("maven3"), "maven");
assert_eq!(format_of("docker"), "oci");
assert_eq!(format_of("helm"), "helm");
assert_eq!(format_of("Raw"), "raw", "the backend's own spelling");
assert_eq!(format_of("Rust"), "cargo");
}
/// **A side door offers no publish** (lane HOL-FIELD-2 P19): where the policy requires a
/// scan, a repository outside the intake chain refuses writes, so the console offers no
/// `cargo publish` or upload for it; it says publishing goes through the intake door.
#[test]
fn a_side_door_offers_reads_and_says_publishing_goes_through_the_intake_door() {
for format in ["cargo", "raw"] {
let c = clients_for_side_door(
"crates",
format,
"https://repo.example.org",
"incoming",
None,
None,
);
let all: String = c.iter().map(|s| s.snippet.as_str()).collect();
assert!(
!all.contains("cargo publish") && !all.contains(" -T "),
"{format}: no publish and no upload: {c:?}"
);
assert!(
all.contains("publishing goes through the intake door")
&& all.contains("'incoming'"),
"{format}: it says where publishing goes: {c:?}"
);
}
let cargo = clients_for_side_door(
"crates",
"cargo",
"https://repo.example.org",
"incoming",
None,
None,
);
assert!(
cargo[0]
.snippet
.contains("sparse+https://repo.example.org/crates/index/"),
"reading stays: {cargo:?}"
);
// HOL-CARGO-PUBLISH-DOOR: with a cargo door, a crates side door sends `cargo publish` there.
let to_door = clients_for_side_door(
"crates-old",
"cargo",
"https://repo.example.org",
"incoming",
Some("crates"),
None,
);
let all: String = to_door.iter().map(|s| s.snippet.as_str()).collect();
assert!(
all.contains("cargo publish --registry crates\n")
&& !all.contains("cargo publish --registry crates-old"),
"{to_door:?}"
);
}
/// **The cargo door offers `cargo publish` again** (lane HOL-CARGO-PUBLISH-DOOR): the
/// registry, the token, publish, install and yank, and a note that a publish walks the
/// intake chain before it is served.
#[test]
fn the_cargo_door_offers_publish_install_and_yank_and_says_it_walks_the_chain() {
let c = clients_for_cargo_door("crates", "https://repo.example.org", None);
let all: String = c.iter().map(|s| s.snippet.as_str()).collect();
for want in [
"sparse+https://repo.example.org/crates/index/",
"token = \"YOUR-TOKEN\"",
"cargo publish --registry crates",
"cargo install --registry crates <crate>",
"cargo yank --registry crates <crate> --version <version>",
"INGESTED",
"AVAILABLE",
] {
assert!(all.contains(want), "{want}: {c:?}");
}
}
#[test]
fn every_snippet_names_the_repository_its_address_and_the_token_placeholder() {
for format in ["cargo", "pip", "npm", "maven", "oci", "deb", "rpm", "raw"] {
let c = clients_for("acme", format, "https://repo.example.org/", None);
assert!(!c.is_empty(), "{format}");
assert!(
c.iter().all(|s| s.snippet.contains("acme")),
"{format}: {c:?}"
);
assert!(
c.iter().any(|s| s.snippet.contains(TOKEN_PLACEHOLDER)),
"{format}: {c:?}"
);
assert!(
c.iter().all(|s| !s.snippet.contains("org//")),
"no doubled slash: {c:?}"
);
}
let cargo = clients_for("acme", "cargo", "https://repo.example.org", None);
assert_eq!(
cargo[0].snippet,
"[registries.acme]\nindex = \"sparse+https://repo.example.org/acme/index/\"\n"
);
let npm = clients_for("acme", "npm", "https://repo.example.org", None);
assert!(
npm[0]
.snippet
.contains("//repo.example.org/acme/:_authToken=YOUR-TOKEN"),
"{npm:?}"
);
}
/// ★ HOL-SIGNED-APT-DNF: **apt and dnf check the metadata Holger signs.** The deb setup
/// fetches the key `/-/repo-key.asc` serves and names it `signed-by=`; the rpm setup has
/// `gpgcheck=1`, `repo_gpgcheck=1` and that key as `gpgkey=`. Neither says
/// `trusted=yes` or `gpgcheck=0`.
#[test]
fn the_apt_and_dnf_setup_verify_the_signed_metadata() {
let deb = clients_for("acme", "deb", "https://repo.example.org", None);
let all: String = deb.iter().map(|s| s.snippet.as_str()).collect();
assert!(
all.contains("https://repo.example.org/-/repo-key.asc"),
"{all}"
);
assert!(
all.contains("deb [signed-by=/etc/apt/keyrings/acme.asc] https://repo.example.org/acme stable main"),
"{all}"
);
let rpm = clients_for("acme", "rpm", "https://repo.example.org", None);
let repo = &rpm[0].snippet;
for want in [
"gpgcheck=1",
"repo_gpgcheck=1",
"gpgkey=https://repo.example.org/-/repo-key.asc",
] {
assert!(repo.contains(want), "{want}: {repo}");
}
for s in deb.iter().chain(&rpm) {
assert!(
!s.snippet.contains("trusted=yes") && !s.snippet.contains("gpgcheck=0"),
"{s:?}"
);
}
}
/// ★ HOL-SIGN-2: **the console names the key a client must trust**: the deb and rpm
/// setup carry THIS box's key fingerprint (a downstream's own, never its upstream's),
/// and a box with no key says so and how to make one.
#[test]
fn the_apt_and_dnf_setup_name_the_boxs_key_by_fingerprint() {
let k =
crate::repo_sign::RepoKey::generate("Holger downstream key", 1_760_000_000).unwrap();
let fpr = k.fingerprint_hex();
for format in ["deb", "rpm"] {
let all: String = clients_for("acme", format, "https://repo.example.org", Some(&k))
.iter()
.map(|s| s.snippet.clone())
.collect();
assert!(
all.contains(&format!("fingerprint {fpr} (Holger downstream key)")),
"{format}: {all}"
);
let none: String = clients_for("acme", format, "https://repo.example.org", None)
.iter()
.map(|s| s.snippet.clone())
.collect();
assert!(
none.contains("holds no repository key") && none.contains("holger repo-key create"),
"{format}: {none}"
);
}
let apt: String = clients_for("acme", "deb", "https://repo.example.org", Some(&k))
.iter()
.map(|s| s.snippet.clone())
.collect();
assert!(
apt.contains("gpg --show-keys /etc/apt/keyrings/acme.asc"),
"{apt}"
);
}
/// ★ RA5-7: **a grant resolves a person by a stable id, never by a display name.** Names
/// are not unique: a stranger who signed up as "Kim" first must never receive the grant an
/// owner meant for the real Kim. The subject (or an e-mail one active person holds) is
/// taken; a name is refused, as is an e-mail two people hold.
#[cfg(feature = "auth-passkey")]
#[test]
fn a_grant_resolves_a_person_by_a_stable_id_never_a_display_name() {
use nornir_identity::UserStore as _;
let root = tempfile::tempdir().unwrap();
let store = crate::identity_host::HolgerStore::open(root.path()).unwrap();
let kim = |id: &str, email: &str| nornir_identity::User {
id: id.into(),
display: "Kim".into(),
email: Some(email.into()),
groups: vec![],
state: nornir_identity::UserState::Active,
created: 1,
};
store
.put_user(&kim("u-stranger", "stranger@mock.invalid"))
.unwrap();
store.put_user(&kim("u-kim", "kim@mock.invalid")).unwrap();
assert_eq!(
person_named(root.path(), "Kim"),
None,
"a display name is no person"
);
assert_eq!(
person_named(root.path(), "u-kim"),
Some(("u-kim".into(), "Kim".into())),
"the subject is"
);
assert_eq!(
person_named(root.path(), " Kim@Mock.Invalid "),
Some(("u-kim".into(), "Kim".into())),
"an e-mail one active person holds is"
);
assert_eq!(person_named(root.path(), "nobody@mock.invalid"), None);
store.put_user(&kim("u-dup-1", "dup@mock.invalid")).unwrap();
store.put_user(&kim("u-dup-2", "dup@mock.invalid")).unwrap();
assert_eq!(
person_named(root.path(), "dup@mock.invalid"),
None,
"an e-mail two people hold names nobody"
);
}
}