chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An organization's admins change its repositories' settings on the pages
.changeset/org-admin-settings.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# An organization's admins change its repositories' settings on the pages | ||
| ||
An admin who joined an organization now sees the Settings tab on its repositories, and changes everything there as the organization: description, branches and their rules, labels, collaborators, triagers, runners and secrets, and archiving. Transferring and deleting a repository stay with the organization's own login. | ||
crates/knotless-serve/src/web/landing.rs+3−1
| @@ -457,7 +457,9 @@ async fn naming( | ||
input: RunnerInput, | ||
add: bool, | ||
) -> Result<Response> { | ||
let (ctx, repo) = acting(app, viewer, headers, "change runners", &input.repo).await?; | ||
// Its owner's, or an organization's admin's, as its other settings. | ||
let act = "change runners"; | ||
let (repo, ctx) = super::settings::owned(app, viewer, headers, &input.repo, act).await?; | ||
let url = url_of(&repo); | ||
let named = manage::set_runner(&ctx, &url, &input.runner, add).await; | ||
let warning = named.map_err(refused)?; | ||
crates/knotless-serve/src/web/settings.rs+31−4
| @@ -3,7 +3,10 @@ | ||
//! `compactRepo`, `transferRepo`, `archiveRepo`, `acceptTransfer` and | ||
//! `deleteRepo`, each | ||
//! through the same code as the command it is named for. Only the owner | ||
//! can make these changes, which is checked here, not only in the pages. | ||
//! 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 | ||
//! is checked here, not only in the pages. | ||
| ||
use super::repos::{RepoMade, refused, rewrote, wrote, wrote_of}; | ||
use super::stars::logged_in; | ||
| @@ -141,7 +144,7 @@ pub async fn get_repo_settings( | ||
| ||
/// The repository `uri` names, for a change only its owner, logged in to | ||
/// the pages, may make, `act`; and a context acting as them. | ||
pub(super) async fn owned( | ||
pub(super) async fn owner_only( | ||
app: &App, | ||
viewer: Viewer, | ||
headers: &HeaderMap, | ||
| @@ -157,6 +160,30 @@ pub(super) async fn owned( | ||
Ok((repo, crate::login::ctx_for(app, &did))) | ||
} | ||
| ||
/// 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. | ||
pub(super) async fn owned( | ||
app: &App, | ||
viewer: Viewer, | ||
headers: &HeaderMap, | ||
uri: &str, | ||
act: &str, | ||
) -> Result<(Repo, Ctx)> { | ||
let did = logged_in(app, viewer, headers, act)?; | ||
let repo = repo::found(app, uri).await?; | ||
if repo.did == did { | ||
return Ok((repo, crate::login::ctx_for(app, &did))); | ||
} | ||
if !app.org_admits(&repo.did, &did) { | ||
let why = format!("only {}/{}'s owner can {act}", repo.authority, repo.name); | ||
return Err(Shown(StatusCode::FORBIDDEN, why)); | ||
} | ||
crate::gateway::organization(app, &repo.did).await?; | ||
Ok((repo, app.ctx.clone())) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct UpdateInput { | ||
| @@ -287,7 +314,7 @@ pub async fn transfer_repo( | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<TransferInput>, | ||
) -> Result<Response> { | ||
let (repo, ctx) = owned(&app, viewer, &headers, &input.repo, "transfer it").await?; | ||
let (repo, ctx) = owner_only(&app, viewer, &headers, &input.repo, "transfer it").await?; | ||
let url = url_of(&repo); | ||
let name = input | ||
.name | ||
| @@ -377,7 +404,7 @@ pub async fn delete_repo( | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<RepoInput>, | ||
) -> Result<Response> { | ||
let (repo, ctx) = owned(&app, viewer, &headers, &input.repo, "delete it").await?; | ||
let (repo, ctx) = owner_only(&app, viewer, &headers, &input.repo, "delete it").await?; | ||
manage::delete(&ctx, &url_of(&repo)) | ||
.await | ||
.map_err(refused)?; | ||
crates/knotless-serve/src/web/views.rs+4−0
| @@ -66,6 +66,10 @@ pub struct RepoViewerState { | ||
/// What the asker chose to hear of it: `all`, `releases` or `ignore`. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub subscription: Option<String>, | ||
/// Whether the asker may change its settings: its owner, or an admin | ||
/// who joined the organization that owns it. | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
pub admin: bool, | ||
} | ||
| ||
/// `dev.knotless.defs#commitView`. | ||
crates/knotless-serve/src/gateway.rs+1−1
| @@ -870,7 +870,7 @@ pub struct MemberCall { | ||
} | ||
| ||
/// The organization `org`'s session, which this gateway keeps. | ||
async fn organization(app: &App, org: &str) -> Result<Session, AppError> { | ||
pub(crate) async fn organization(app: &App, org: &str) -> Result<Session, AppError> { | ||
gateway(app)?; | ||
let id = identity::resolve(&app.ctx, org) | ||
.await | ||
crates/knotless-serve/src/web.rs+1−0
| @@ -1474,6 +1474,7 @@ impl Repo { | ||
transfer, | ||
labels: labels.collect(), | ||
subscription, | ||
admin: app.org_admits(&self.did, viewer), | ||
} | ||
} | ||
| ||
crates/knotless-serve/tests/gateway/orgs.rs+30−0
| @@ -216,6 +216,36 @@ fn organizations_invite_and_members_join() { | ||
assert!(theirs.contains("is not an admin of"), "{theirs}"); | ||
ok(&member, &["secret", &byadmin, "rm", "TOKEN"]); | ||
assert_eq!(ok(&member, &["secret", &byadmin, "ls"]), ""); | ||
| ||
// On the pages, an admin changes its settings, written as the | ||
// organization, but neither transfers nor deletes it; anyone else | ||
// changes none of them. | ||
let at = format!("at://{od}/dev.knotless.repo/byadmin"); | ||
let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", gw.base); | ||
let admin = session_cookie(&gw, &member.account); | ||
let other = session_cookie(&gw, &stranger.account); | ||
let (a, x) = (Some(admin.as_str()), Some(other.as_str())); | ||
let write = | ||
|nsid: &str, cookie, body: Value| browse("POST", &xrpc(nsid), cookie, None, Some(body)).0; | ||
let may = |cookie| { | ||
let url = format!("{}?repo={at}", xrpc("getRepo")); | ||
browse("GET", &url, cookie, None, None).2["viewer"]["admin"].clone() | ||
}; | ||
assert_eq!((may(a), may(x)), (json!(true), Value::Null)); | ||
let described = json!({"repo": at, "description": "Set by an admin"}); | ||
assert_eq!(write("updateRepo", x, described.clone()), 403); | ||
assert_eq!(write("updateRepo", a, described), 200); | ||
let record = || org.record("dev.knotless.repo", "byadmin"); | ||
assert_eq!(record()["description"], "Set by an admin"); | ||
assert_eq!( | ||
write("protectBranch", a, json!({"repo": at, "branch": "main"})), | ||
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}"); | ||
} | ||
assert!(record().is_object()); | ||
// The gateway acts only for an organization that delegated to it. | ||
let lxm = "dev.knotless.joinOrg"; | ||
let body = json!({"org": stranger.account.did}) | ||
crates/knotless-serve/tests/gateway.rs+1−1
| @@ -1826,7 +1826,7 @@ fn landing_pulls_from_the_pages() { | ||
let runners = |cookie| ask("listRunners", &format!("repo={repo}"), cookie).2; | ||
assert_eq!(runners(None), json!({"runners": []})); | ||
let runner = json!({"repo": repo, "runner": ci.account.handle}); | ||
assert_eq!(write("addRunner", them, runner.clone()).0, 400); | ||
assert_eq!(write("addRunner", them, runner.clone()).0, 403); | ||
assert_eq!(write("addRunner", None, runner.clone()).0, 401); | ||
let (code, _, named) = write("addRunner", me, runner.clone()); | ||
assert_eq!(code, 200, "{named}"); | ||
docs/design/security.md+3−1
| @@ -91,7 +91,9 @@ 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 takes members in and out of its spaces as they | ||
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 | ||
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+8−3
| @@ -927,7 +927,8 @@ refreshed, so the page it goes on to, whose empty state says how to push, | ||
and the account's profile show it. | ||
| ||
A repository's owner sees a Settings tab, at `settings`, with a side nav | ||
of its sections. General sets its description, links, topics | ||
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 | ||
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. | ||
| @@ -948,8 +949,12 @@ pages' session is never handed to the gateway, whose grant is narrower | ||
and lasts longer ([gateway.md](gateway.md#delegation)). What | ||
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 alone, which the server checks, not only the pages: anyone | ||
else is refused 403, whatever they send. The account a repository is | ||
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 | ||
offered to sees the offer on its pages, and takes it up there, as | ||
`knotless accept` does. | ||
| ||