chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Admins name agents, and a new triager's warning is no longer dropped
.changeset/org-admin-cli.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# An organization's admins change its repositories' settings from the command line | ||
| ||
`knotless describe`, `archive`, `compact`, `protect`, `unprotect`, `labels`, `triagers`, `agents`, `share`, `unshare` and `runners`, and the MCP tools that do the same, now work on an organization's repository from an admin's own login. Not logged in as the owner, they ask the appview, which makes the change as the organization. Log in again (`knotless login`) if your login is older than this, so it may ask. Renaming, deleting and transferring a repository still take the organization's own login, on the pages too. | ||
crates/knotless/src/compact.rs+18−2
| @@ -20,7 +20,7 @@ use crate::mirror::Mirror; | ||
use crate::push; | ||
use crate::records::{self, nsid}; | ||
use crate::remote::{self, Remote, Snapshot}; | ||
use crate::session::{self, Session}; | ||
use crate::session::Session; | ||
use crate::url::RepoUrl; | ||
use anyhow::{Context, Result}; | ||
use serde::Serialize; | ||
| @@ -40,7 +40,23 @@ pub struct Compacted { | ||
/// Compacts the repository at `url`, as its owner logged in here. | ||
pub async fn compact_repo(ctx: &Ctx, url: &RepoUrl) -> Result<Compacted> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match crate::manage::acting(ctx, &remote).await? { | ||
crate::manage::Acting::Owner(auth) => auth, | ||
// An organization's admin, which the appview compacts it for. | ||
crate::manage::Acting::Admin(me) => { | ||
let body = serde_json::json!({}); | ||
let compact = crate::manage::COMPACT_REPO; | ||
let done = crate::manage::as_admin(ctx, &me, &remote, url, compact, body).await?; | ||
let n = |k: &str| done[k].as_u64().unwrap_or_default() as usize; | ||
let (before, removed, claims) = (n("packs"), n("folded"), n("claims")); | ||
return Ok(Compacted { | ||
before, | ||
removed, | ||
claims, | ||
refs: n("refs"), | ||
}); | ||
} | ||
}; | ||
compact(ctx, &remote, &auth).await | ||
} | ||
| ||
crates/knotless/src/manage.rs+164−8
| @@ -13,6 +13,71 @@ use crate::session::Session; | ||
use crate::space::{Admin, Space}; | ||
use crate::url::RepoUrl; | ||
use anyhow::{Context, Result, bail}; | ||
use serde_json::json; | ||
| ||
/// The appview's methods an organization's admin changes its | ||
/// repositories' settings with, which its gateway writes as the | ||
/// organization: the pages' own, taking a service token. | ||
pub const UPDATE_REPO: &str = "dev.knotless.updateRepo"; | ||
pub const COMPACT_REPO: &str = "dev.knotless.compactRepo"; | ||
pub const ARCHIVE_REPO: &str = "dev.knotless.archiveRepo"; | ||
pub const PROTECT_BRANCH: &str = "dev.knotless.protectBranch"; | ||
pub const UNPROTECT_BRANCH: &str = "dev.knotless.unprotectBranch"; | ||
pub const DEFINE_LABEL: &str = "dev.knotless.defineLabel"; | ||
pub const REMOVE_LABEL: &str = "dev.knotless.removeLabel"; | ||
pub const SHARE_REPO: &str = "dev.knotless.shareRepo"; | ||
pub const UNSHARE_REPO: &str = "dev.knotless.unshareRepo"; | ||
pub const SET_TRIAGER: &str = "dev.knotless.setTriager"; | ||
pub const REMOVE_TRIAGER: &str = "dev.knotless.removeTriager"; | ||
pub const ADD_AGENT: &str = "dev.knotless.addAgent"; | ||
pub const REMOVE_AGENT: &str = "dev.knotless.removeAgent"; | ||
pub const ADD_RUNNER: &str = "dev.knotless.addRunner"; | ||
pub const REMOVE_RUNNER: &str = "dev.knotless.removeRunner"; | ||
| ||
/// Who changes a repository's settings: its owner, logged in here as them, | ||
/// who writes them; or else the account logged in, who asks the appview, | ||
/// which lets in an admin who joined the organization that owns it. | ||
pub(crate) enum Acting { | ||
Owner(Session), | ||
Admin(Session), | ||
} | ||
| ||
/// Who changes `remote`'s settings ([`Acting`]): not logged in at all, | ||
/// that fails as ever. | ||
pub(crate) async fn acting(ctx: &Ctx, remote: &Remote) -> Result<Acting> { | ||
if let Ok(owner) = session::ensure(ctx, &remote.identity).await { | ||
return Ok(Acting::Owner(owner)); | ||
} | ||
let me = session::me(ctx).await?; | ||
Ok(Acting::Admin(me)) | ||
} | ||
| ||
/// Asks the appview, as `me`, an admin of the organization that owns the | ||
/// repository at `url`, to change it with `method`, the rest of `body` | ||
/// saying how. What it answers. | ||
pub(crate) async fn as_admin( | ||
ctx: &Ctx, | ||
me: &Session, | ||
remote: &Remote, | ||
url: &RepoUrl, | ||
method: &str, | ||
mut body: serde_json::Value, | ||
) -> Result<serde_json::Value> { | ||
let (xrpc, did) = crate::invites::appview(ctx).await?; | ||
let token = crate::auth::service_token(ctx, me, &did, method).await?; | ||
body["repo"] = format!("at://{}/{}/{}", remote.did(), nsid::REPO, url.name).into(); | ||
let auth = crate::xrpc::Auth::Bearer(token); | ||
xrpc.procedure(method, Some(&body), Some(&auth)).await | ||
} | ||
| ||
/// The repository's record as it stands after an admin's change. | ||
async fn changed(remote: &Remote, url: &RepoUrl) -> Result<RepoRecord> { | ||
let repo = remote.repo().await?; | ||
// Said now, not in a closure: one gone since it was changed is not a | ||
// case any test can bring about. | ||
let missing = format!("{url}: no such repository"); | ||
repo.map(|r| r.value).context(missing) | ||
} | ||
| ||
#[derive(Clone, Debug, Default)] | ||
pub struct NewRepo { | ||
| @@ -390,7 +455,17 @@ async fn membership( | ||
async fn set_access(ctx: &Ctx, url: &RepoUrl, who: &str, access: Option<bool>) -> Result<()> { | ||
let who = identity::resolve(ctx, who).await?; | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let (method, body) = match access { | ||
Some(write) => (SHARE_REPO, json!({"with": who.did, "write": write})), | ||
None => (UNSHARE_REPO, json!({"with": who.did})), | ||
}; | ||
as_admin(ctx, &me, &remote, url, method, body).await?; | ||
return Ok(()); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -451,7 +526,22 @@ pub async fn protect( | ||
bail!("a branch asks for 0 to 10 approvals, not {n}"); | ||
} | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let (method, body) = match &rule { | ||
Some(r) => { | ||
let merges = r.history.as_deref() == Some("merges"); | ||
let reviews = r.reviews.clone().unwrap_or_default(); | ||
let body = json!({"branch": branch, "merges": merges, "queue": r.queue, "reviews": reviews}); | ||
(PROTECT_BRANCH, body) | ||
} | ||
None => (UNPROTECT_BRANCH, json!({"branch": branch})), | ||
}; | ||
as_admin(ctx, &me, &remote, url, method, body).await?; | ||
return Ok(changed(&remote, url).await?.rules); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -499,7 +589,21 @@ pub async fn define_label( | ||
bail!("{color} is not a color: give one as #d73a4a"); | ||
} | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let (method, body) = match &label { | ||
Some(l) => { | ||
let (color, about) = (&l.color, &l.description); | ||
let body = json!({"name": name, "color": color, "description": about, "exclusive": l.exclusive}); | ||
(DEFINE_LABEL, body) | ||
} | ||
None => (REMOVE_LABEL, json!({"name": name})), | ||
}; | ||
as_admin(ctx, &me, &remote, url, method, body).await?; | ||
return Ok(changed(&remote, url).await?.labels); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -551,7 +655,21 @@ pub async fn set_triager( | ||
} | ||
let who = identity::resolve(ctx, who).await?; | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let (method, body) = match scopes { | ||
Some(scopes) => { | ||
let named: Vec<_> = scopes.iter().map(|n| json!({"name": n})).collect(); | ||
(SET_TRIAGER, json!({"who": who.did, "scopes": named})) | ||
} | ||
None => (REMOVE_TRIAGER, json!({"who": who.did})), | ||
}; | ||
let said = as_admin(ctx, &me, &remote, url, method, body).await?; | ||
let warning = said["warning"].as_str().map(str::to_string); | ||
return Ok((changed(&remote, url).await?.triagers, warning)); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -598,7 +716,15 @@ pub async fn triagers(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<records::Triager>> | ||
pub async fn set_agent(ctx: &Ctx, url: &RepoUrl, who: &str, add: bool) -> Result<()> { | ||
let who = identity::resolve(ctx, who).await?; | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let method = if add { ADD_AGENT } else { REMOVE_AGENT }; | ||
let body = json!({"agent": who.did}); | ||
as_admin(ctx, &me, &remote, url, method, body).await?; | ||
return Ok(()); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -648,7 +774,15 @@ pub async fn rules(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<records::Rule>> { | ||
pub async fn set_runner(ctx: &Ctx, url: &RepoUrl, who: &str, add: bool) -> Result<Option<String>> { | ||
let who = identity::resolve(ctx, who).await?; | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let method = if add { ADD_RUNNER } else { REMOVE_RUNNER }; | ||
let body = json!({"runner": who.did}); | ||
let said = as_admin(ctx, &me, &remote, url, method, body).await?; | ||
return Ok(said["warning"].as_str().map(str::to_string)); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -930,7 +1064,22 @@ pub async fn describe(ctx: &Ctx, url: &RepoUrl, change: Description<'_>) -> Resu | ||
None => None, | ||
}; | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let topics = about.map(|t| t.into_iter().map(|t| t.name).collect::<Vec<_>>()); | ||
let topics: Option<Vec<_>> = | ||
topics.map(|t| t.iter().map(|n| json!({"name": n})).collect()); | ||
let body = json!({ | ||
"description": description, | ||
"links": given, | ||
"topics": topics, | ||
"defaultBranch": default_branch, | ||
}); | ||
as_admin(ctx, &me, &remote, url, UPDATE_REPO, body).await?; | ||
return changed(&remote, url).await; | ||
} | ||
}; | ||
let snap = remote.snapshot(ctx).await?; | ||
let Some(repo) = snap.repo.clone() else { | ||
bail!("{url}: no such repository"); | ||
| @@ -1108,7 +1257,14 @@ pub async fn transfer(ctx: &Ctx, url: &RepoUrl, to: Option<&RepoUrl>) -> Result< | ||
/// can. Answers when it was archived, if it now is. | ||
pub async fn archive(ctx: &Ctx, url: &RepoUrl, archived: bool) -> Result<Option<String>> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let auth = match acting(ctx, &remote).await? { | ||
Acting::Owner(auth) => auth, | ||
Acting::Admin(me) => { | ||
let body = json!({"unarchive": !archived}); | ||
as_admin(ctx, &me, &remote, url, ARCHIVE_REPO, body).await?; | ||
return Ok(changed(&remote, url).await?.archived_at); | ||
} | ||
}; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
crates/knotless/src/oauth.rs+15−0
| @@ -95,6 +95,21 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| { | ||
crate::secrets::PUT, | ||
crate::secrets::DELETE, | ||
crate::secrets::LIST, | ||
crate::manage::UPDATE_REPO, | ||
crate::manage::COMPACT_REPO, | ||
crate::manage::ARCHIVE_REPO, | ||
crate::manage::PROTECT_BRANCH, | ||
crate::manage::UNPROTECT_BRANCH, | ||
crate::manage::DEFINE_LABEL, | ||
crate::manage::REMOVE_LABEL, | ||
crate::manage::SHARE_REPO, | ||
crate::manage::UNSHARE_REPO, | ||
crate::manage::SET_TRIAGER, | ||
crate::manage::REMOVE_TRIAGER, | ||
crate::manage::ADD_AGENT, | ||
crate::manage::REMOVE_AGENT, | ||
crate::manage::ADD_RUNNER, | ||
crate::manage::REMOVE_RUNNER, | ||
crate::notifications::LIST, | ||
crate::notifications::SEEN, | ||
crate::notifications::GET_PREFERENCES, | ||
crates/knotless-serve/src/web/controls.rs+61−5
| @@ -1,9 +1,11 @@ | ||
//! What a repository's settings change besides its name and place: its | ||
//! branches (`dev.knotless.createBranch`, `deleteBranch`, `renameBranch`), | ||
//! which branches are protected (`protectBranch`, `unprotectBranch`), its | ||
//! labels (`defineLabel`, `removeLabel`), and who may push to it or label | ||
//! it (`shareRepo`, `unshareRepo`, `setTriager`, `removeTriager`), each | ||
//! through the same code as the command it is named for. | ||
//! labels (`defineLabel`, `removeLabel`), who may push to it or label it | ||
//! (`shareRepo`, `unshareRepo`, `setTriager`, `removeTriager`), and whose | ||
//! pulls its runners check as code no one vouches for (`addAgent`, | ||
//! `removeAgent`), each through the same code as the command it is named | ||
//! for. | ||
//! | ||
//! Branches are made and deleted as `knotless branch` does them, by the | ||
//! owner directly and by a collaborator through the gateway, which refuses | ||
| @@ -311,6 +313,9 @@ pub struct ScopeInput { | ||
#[derive(Serialize)] | ||
pub struct Triagers { | ||
triagers: Vec<super::settings::TriagerView>, | ||
/// Why a private repository's new triager was not told of it. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
warning: Option<String>, | ||
} | ||
| ||
/// `dev.knotless.setTriager`. | ||
| @@ -346,11 +351,62 @@ async fn triaging( | ||
let names: Vec<&str> = input.scopes.iter().map(|s| s.name.trim()).collect(); | ||
let scopes = on.then_some(names.as_slice()); | ||
let set = manage::set_triager(&ctx, &url_of(&repo), input.who.trim(), scopes).await; | ||
let (set, _) = set.map_err(refused)?; | ||
let (set, warning) = set.map_err(refused)?; | ||
rewrote(app, &repo).await?; | ||
let mut triagers = Vec::new(); | ||
for t in set { | ||
triagers.push(super::settings::TriagerView::new(app, t).await); | ||
} | ||
Ok(answer(Triagers { triagers })) | ||
Ok(answer(Triagers { triagers, warning })) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct AgentInput { | ||
repo: String, | ||
agent: String, | ||
} | ||
| ||
/// `dev.knotless.addAgent`'s and `removeAgent`'s answer. | ||
#[derive(Serialize)] | ||
pub struct Agents { | ||
agents: Vec<ActorView>, | ||
} | ||
| ||
/// `dev.knotless.addAgent`. | ||
pub async fn add_agent( | ||
State(app): State<Arc<App>>, | ||
Extension(viewer): Extension<Viewer>, | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<AgentInput>, | ||
) -> Result<Response> { | ||
naming_agent(&app, viewer, &headers, input, true).await | ||
} | ||
| ||
/// `dev.knotless.removeAgent`. | ||
pub async fn remove_agent( | ||
State(app): State<Arc<App>>, | ||
Extension(viewer): Extension<Viewer>, | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<AgentInput>, | ||
) -> Result<Response> { | ||
naming_agent(&app, viewer, &headers, input, false).await | ||
} | ||
| ||
/// Names `input.agent` among the repository's agents, or with `add` false | ||
/// no longer, as `knotless agents add` and `remove` do. | ||
async fn naming_agent( | ||
app: &Arc<App>, | ||
viewer: Viewer, | ||
headers: &HeaderMap, | ||
input: AgentInput, | ||
add: bool, | ||
) -> Result<Response> { | ||
let (repo, ctx) = owned(app, viewer, headers, &input.repo, "name its agents").await?; | ||
let url = url_of(&repo); | ||
let named = manage::set_agent(&ctx, &url, input.agent.trim(), add).await; | ||
named.map_err(refused)?; | ||
rewrote(app, &repo).await?; | ||
let dids = manage::agents(&ctx, &url).await.map_err(refused)?; | ||
let agents = futures::future::join_all(dids.iter().map(|d| actor(app, d))).await; | ||
Ok(answer(Agents { agents })) | ||
} | ||
crates/knotless-serve/src/web/settings.rs+22−5
| @@ -5,7 +5,8 @@ | ||
//! through the same code as the command it is named for. Only the owner | ||
//! can make these changes, or, for an organization's repository, an admin | ||
//! who joined it, as the organization through the gateway it delegated to | ||
//! here; deleting and transferring it stay the organization's own. This | ||
//! here; renaming, transferring and deleting it stay the organization's | ||
//! own. This | ||
//! is checked here, not only in the pages. | ||
| ||
use super::repos::{RepoMade, refused, rewrote, wrote, wrote_of}; | ||
| @@ -163,7 +164,11 @@ pub(super) async fn owner_only( | ||
/// The repository `uri` names, for a change to its settings, `act`, that | ||
/// its owner may make, or an admin who joined the organization that owns | ||
/// it; and a context acting as the owner: for an admin, the organization's | ||
/// session that its gateway, this server, keeps. | ||
/// session that its gateway, this server, keeps. An admin asks with the | ||
/// pages' login, or with a service token from their own PDS for the | ||
/// method, as `knotless` does; a token, which the viewer middleware checked, | ||
/// is only ever taken as asking for the gateway to write, never for the | ||
/// pages' session of whoever it names. | ||
pub(super) async fn owned( | ||
app: &App, | ||
viewer: Viewer, | ||
| @@ -171,9 +176,17 @@ pub(super) async fn owned( | ||
uri: &str, | ||
act: &str, | ||
) -> Result<(Repo, Ctx)> { | ||
let did = logged_in(app, viewer, headers, act)?; | ||
// As the viewer middleware, which verifies only a bearer token. | ||
let token = headers | ||
.get(header::AUTHORIZATION) | ||
.and_then(|v| v.to_str().ok()) | ||
.is_some_and(|v| v.starts_with("Bearer ")); | ||
let did = match viewer.0.clone().filter(|_| token) { | ||
Some(did) => did, | ||
None => logged_in(app, viewer, headers, act)?, | ||
}; | ||
let repo = repo::found(app, uri).await?; | ||
if repo.did == did { | ||
if repo.did == did && !token { | ||
return Ok((repo, crate::login::ctx_for(app, &did))); | ||
} | ||
if !app.org_admits(&repo.did, &did) { | ||
| @@ -250,7 +263,9 @@ pub async fn rename_repo( | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<RenameInput>, | ||
) -> Result<Response> { | ||
let (repo, ctx) = owned(&app, viewer, &headers, &input.repo, "rename it").await?; | ||
// Renaming it leaves a redirect, which an organization's grant to its | ||
// gateway does not reach: its own, as moving and deleting it are. | ||
let (repo, ctx) = owner_only(&app, viewer, &headers, &input.repo, "rename it").await?; | ||
let renamed = manage::rename(&ctx, &url_of(&repo), input.name.trim()).await; | ||
let to = renamed.map_err(refused)?; | ||
let ops = [ | ||
| @@ -273,6 +288,7 @@ pub struct Compacted { | ||
packs: usize, | ||
folded: usize, | ||
claims: usize, | ||
refs: usize, | ||
} | ||
| ||
/// `dev.knotless.compactRepo`. | ||
| @@ -290,6 +306,7 @@ pub async fn compact_repo( | ||
packs: c.before, | ||
folded: c.removed, | ||
claims: c.claims, | ||
refs: c.refs, | ||
})) | ||
} | ||
| ||
crates/knotless-serve/src/web.rs+14−1
| @@ -166,6 +166,11 @@ pub fn queries() -> Router<Arc<App>> { | ||
post(controls::unshare_repo), | ||
) | ||
.route("/xrpc/dev.knotless.setTriager", post(controls::set_triager)) | ||
.route("/xrpc/dev.knotless.addAgent", post(controls::add_agent)) | ||
.route( | ||
"/xrpc/dev.knotless.removeAgent", | ||
post(controls::remove_agent), | ||
) | ||
.route( | ||
"/xrpc/dev.knotless.removeTriager", | ||
post(controls::remove_triager), | ||
| @@ -1474,10 +1479,18 @@ impl Repo { | ||
transfer, | ||
labels: labels.collect(), | ||
subscription, | ||
admin: app.org_admits(&self.did, viewer), | ||
admin: self.administered(app, viewer), | ||
} | ||
} | ||
| ||
/// Whether `viewer` may change its settings: as its owner, or as an | ||
/// admin who joined the organization that owns it, when that delegated | ||
/// to this server's gateway, which keeps its session to write as it. | ||
fn administered(&self, app: &App, viewer: &str) -> bool { | ||
let kept = || app.gateway.is_some() && session::load(&app.ctx, &self.did).is_some(); | ||
viewer == self.did || (app.org_admits(&self.did, viewer) && kept()) | ||
} | ||
| ||
/// The repository as views name it, by its owner's handle. | ||
async fn basic(&self, app: &App) -> RepoViewBasic { | ||
RepoViewBasic { | ||
crates/knotless-serve/tests/gateway/orgs.rs+63−2
| @@ -242,8 +242,69 @@ fn organizations_invite_and_members_join() { | ||
200 | ||
); | ||
assert_eq!(record()["rules"][0]["branch"], "main"); | ||
for nsid in ["transferRepo", "deleteRepo"] { | ||
assert_eq!(write(nsid, a, json!({"repo": at, "to": s})), 403, "{nsid}"); | ||
for nsid in ["renameRepo", "transferRepo", "deleteRepo"] { | ||
let body = json!({"repo": at, "to": s, "name": "byadmin2"}); | ||
assert_eq!(write(nsid, a, body), 403, "{nsid}"); | ||
} | ||
assert!(record().is_object()); | ||
| ||
// From the command line too, from the admin's own login: each change | ||
// is the appview's, written as the organization; a stranger's is not. | ||
let theirs = refused(&stranger, &["protect", &byadmin, "main"]); | ||
assert!(theirs.contains("only"), "{theirs}"); | ||
ok( | ||
&member, | ||
&[ | ||
"describe", | ||
&byadmin, | ||
"--description", | ||
"From the CLI", | ||
"--topic", | ||
"cli", | ||
], | ||
); | ||
assert_eq!(record()["description"], "From the CLI"); | ||
assert_eq!(record()["topics"][0]["name"], "cli"); | ||
ok( | ||
&member, | ||
&["protect", &byadmin, "main", "--queue", "--reviews", "1"], | ||
); | ||
assert_eq!(record()["rules"][0]["queue"], true); | ||
assert_eq!(record()["rules"][0]["reviews"]["approvals"], 1); | ||
ok(&member, &["unprotect", &byadmin, "main"]); | ||
assert_eq!(record()["rules"], Value::Null); | ||
let label = ["labels", &byadmin, "add", "kind/bug", "--color", "#d73a4a"]; | ||
ok(&member, &label); | ||
assert_eq!(record()["labels"][0]["color"], "#d73a4a"); | ||
ok(&member, &["triagers", &byadmin, "add", s, "kind"]); | ||
assert_eq!(record()["triagers"][0]["did"], sd); | ||
ok(&member, &["triagers", &byadmin, "remove", s]); | ||
ok(&member, &["agents", &byadmin, "add", s]); | ||
assert_eq!(record()["agents"][0]["did"], sd); | ||
ok(&member, &["agents", &byadmin, "remove", s]); | ||
assert_eq!(record()["agents"], Value::Null); | ||
ok(&member, &["labels", &byadmin, "remove", "kind/bug"]); | ||
assert_eq!(record()["labels"], Value::Null); | ||
ok(&member, &["share", &byadmin, t, "--write"]); | ||
let pushers = |r: Value| r["collaborators"].as_array().map_or(0, Vec::len); | ||
assert_eq!(pushers(record()), 1); | ||
ok(&member, &["unshare", &byadmin, t]); | ||
assert_eq!(pushers(record()), 0); | ||
ok(&member, &["runners", &byadmin, "remove", s]); | ||
ok(&member, &["runners", &byadmin, "add", s]); | ||
assert_eq!(record()["runners"][0]["did"], sd); | ||
ok(&member, &["archive", &byadmin]); | ||
assert!(record()["archivedAt"].is_string()); | ||
ok(&member, &["archive", &byadmin, "--undo"]); | ||
let compacted = ok(&member, &["compact", &byadmin]); | ||
assert!(compacted.contains("packs"), "{compacted}"); | ||
// Renaming and deleting it stay the organization's own. | ||
for args in [ | ||
&["rename", &byadmin, "byadmin2"][..], | ||
&["delete", &byadmin, "--yes"], | ||
] { | ||
let kept = refused(&member, args); | ||
assert!(kept.contains("not logged in as"), "{args:?}: {kept}"); | ||
} | ||
assert!(record().is_object()); | ||
// The gateway acts only for an organization that delegated to it. | ||
docs/design/security.md+5−2
| @@ -94,8 +94,11 @@ anyone. | ||
An organization's gateway also holds its member list: it invites, | ||
removes and makes repositories for the organization's admins, who call it | ||
from their own logins, and, when the gateway is the appview too, changes | ||
its repositories' settings for them from the pages, all but offering one | ||
to another account or deleting it, which stay the organization's own, and takes members in and out of its spaces as they | ||
its repositories' settings for them, from the pages or from a service | ||
token of their own, as the command line asks, all but renaming one, | ||
offering it to another account or deleting it, which stay the | ||
organization's own (a rename leaves a redirect, which its grant to the | ||
gateway does not reach), and takes members in and out of its spaces as they | ||
join and leave. It acts only for the organization, an admin who joined, or | ||
the member themselves. A hidden member is seen by the gateway and the organization's | ||
members, and named for each private repository's readers in its space; | ||
docs/design/serve.md+7−5
| @@ -930,7 +930,8 @@ and the account's profile show it. | ||
| ||
A repository's owner sees a Settings tab, at `settings`, with a side nav | ||
of its sections, and so, for an organization's repository, does an admin | ||
who joined it (`getRepo`'s `viewer.admin`). General sets its description, links, topics | ||
who joined it, once the organization has delegated its writes to this | ||
server's gateway (`getRepo`'s `viewer.admin`). General sets its description, links, topics | ||
and default branch (`knotless describe`), renames it, shows what it takes up and | ||
compacts it, and has a Danger zone to offer it to another account or | ||
withdraw the offer, and to delete it once its full name is typed. | ||
| @@ -953,10 +954,11 @@ the page shows is `getRepoSettings`, read from the repository's record as | ||
its PDS has it now, not the index, so a change shows at once. Each change | ||
is the owner's, which the server checks, not only the pages: anyone else | ||
is refused 403, whatever they send. An admin who joined the organization | ||
that owns it makes each but two of them as the organization, through the | ||
session its gateway keeps when the organization delegated to this server; | ||
offering it to another account and deleting it stay the organization's | ||
own, and its Danger zone tells the admin so. The account a repository is | ||
that owns it makes each but three of them as the organization, through | ||
the session its gateway keeps when the organization delegated to this | ||
server; renaming it, offering it to another account and deleting it stay | ||
the organization's own, and the page tells the admin so where each would | ||
be. The account a repository is | ||
offered to sees the offer on its pages, and takes it up there, as | ||
`knotless accept` does. | ||
| ||