~ 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 from the command line

Closed
Closed chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/org-admin-cli

What changes

Admins change settings from the command line and MCP. knotless describe, archive, compact, protect, unprotect, labels add and remove, triagers add and remove, share, unshare and runners add and remove work on an organization's repository from an admin's own login. So do the MCP tools built on them (update_repo, archive_repo, compact, protect_branch, define_label, triagers, share, runners). When the CLI is not logged in as the repository's owner, manage::acting falls back to the account that is logged in. It asks the appview's own settings method for the change with a service token, then reads the record back, so each command prints what it always has.

Renaming, deleting and transferring stay the organization's own. A rename leaves a dev.knotless.redirect record behind, and the organization's grant to the gateway does not reach that collection, so the gateway's write failed with a 403. renameRepo now takes the owner's own login, as deleteRepo and transferRepo do. The pages hide the rename form from an admin and say only the organization can rename it, which also fixes the pages' admin rename that is broken on main.

The pages' settings methods take a service token, for the admin path only. owned() takes a bearer token, which the viewer middleware already verified for the method, and treats it only as a request for the gateway to write as the organization. It never uses the pages' session of whoever the token names, so the owner path is unchanged and still takes the pages' login alone. Only an Authorization: Bearer header counts, as only that is verified.

The CLI's login asks for those methods. The OAuth scope gains rpc: for the thirteen methods. A login from before this has to log in again before acting as an admin.

The Settings tab shows only where it works. viewer.admin is true for an admin only when this server's gateway keeps the organization's session, so an organization that delegated elsewhere no longer shows its admins a tab whose every change is refused.

Why

The rest of An org's admins can't change its repositories' settings, after An organization's admins change its repositories' settings on the pages gave admins the pages. Reusing the pages' methods keeps one check and one write path for every way an admin asks. The issue suggested new gateway methods instead, but each would duplicate a pages method.

Fixes An org's admins can't change its repositories' settings

How it was checked

organizations_invite_and_members_join has the admin run each of those commands on the organization's repository from their own login, each read back from the organization's record. A stranger's protect is refused, and the admin's rename and delete are refused for want of the organization's login. The e2e flows now invite alice to bob.test as an admin, and a new flow, changing an organization's repository as its admin, has her open bob.test/wordlist's Settings tab, save its description, read it back from bob.test's PDS, and see the Danger zone without Delete. The rest is left to CI.

Shortcomings

triagers add as an admin prints no warning when a private repository's triager could not be invited, and agents is still the owner's alone: the next pull, Admins name agents, and a new triager's warning is no longer dropped, closes both. Renaming as an admin would need the gateway's grant to take redirects.

Shots-moved: settings

🤖 Generated with Claude Code

Approved an earlier version

I could not read all of this pull. The diff runs to about 1,400 lines and includes the stacked secrets and pages commits. My tools returned it only as an oversized dump I could not page through, so I read owned() in crates/knotless-serve/src/web/settings.rs and the service-token checks around it. I did not read the CLI's manage::acting, the MCP changes, the scope change or the tests.

What I read in owned() holds up:

  • A bearer token is only ever read as a request for the gateway to write as the organization. It never selects the pages' session of the account the token names, so the owner path is unchanged.
  • A token whose caller is the repository's owner falls through to org_admits. That fails for a personal repository, so it is refused.
  • Admins are checked against the organization's record, and the gateway's session must exist (gateway::organization) before the write.

One thing to confirm, which I could not trace in the middleware: token is true for any Authorization header, while viewer.0 may come from a pages cookie. Check that the viewer middleware sets the viewer from the header alone whenever an Authorization header is present, and rejects one that does not verify. If it does, this is sound.

I am approving on what I could read. CI and the new organizations_invite_and_members_join and e2e flows cover the rest.

Approved an earlier version

This pull lets an organization's admins change its repositories' settings from the CLI and MCP. They do it with a service token that the pages' settings methods accept for the admin path. I looked again at the point I left open last round.

  • Resolved: In viewer.rs, the viewer middleware verifies any Bearer token against the gateway's DID and the method name (lxm), and rejects it if it doesn't verify. Only a request with no Bearer token falls back to the cookie. A verified token therefore sets the viewer, and owned() treats it only as a request for the gateway to write as the organization. A token whose caller is the repository's owner is refused, because org_admits fails for it.
  • Minor, not blocking: owned() sets token for any Authorization header, but the middleware only verifies headers that start with Bearer . A non-Bearer header such as Basic x sent with a pages cookie takes the cookie's viewer down the admin path. That path skips the same_site check in logged_in when the viewer comes from the cookie. A cross-site page can't set Authorization without a CORS preflight, and the cookie's owner would still have to be an admin of the organization. So this isn't exploitable, but owned() is safer if it sets token only for a Bearer header, which is also what the middleware checks.

I did not re-read the CLI side (manage::acting), the scope change or the tests. CI and the new flows cover those.

Approved

Both changes since my last round do what they say, and I found nothing that blocks merging.

  • Bearer-only token: owned() now sets token only for an Authorization header that starts with Bearer , as the viewer middleware verifies. That closes the minor point I raised last round.
  • Rename stays the organization's own: renameRepo now uses owner_only, which takes the pages' login alone. manage::rename goes back to the owner's session and drops the admin path. RENAME_REPO is gone from the CLI's OAuth scope. The pages hide the rename form from an admin. The gateway-flow test now expects 403 for renameRepo and a refusal from the CLI's rename.
  • Other changes: the compact refs count is plumbed through the lexicon, the server and the CLI. The docs and changeset were updated to match.

I did not re-read the CLI's acting path or the e2e flows. CI covers those.

Closing: everything this pull adds is already on main, from the agents pull (3mze7avimcvsl), which carried these commits. What is left is only conflicts where main has the newer version. Its ejection was a timing flake in a compaction test, fixed in Compaction's base test leaves its young pack room to stay young.