~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

An organization's admins change its repositories' settings on the pages

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/org-admin-settings
.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.
docs/reference/api.md+9−5
lexicons/dev/knotless/defs.json+2−1
web/src/lib/organisms/DangerZone.svelte+35−27
web/src/lib/organisms/RepoHeader.svelte+3−3
web/src/lib/organisms/SecretList.svelte+3−3
web/src/lib/templates/RepoPage.svelte+1−1
web/src/lib/templates/RepoSettings.svelte+2−2
web/src/routes/[authority]/[repo]/settings/actions/+page.ts+2−2
web/src/routes/[authority]/[repo]/settings/+page.svelte+1−1