Revoke any pending invitation, drop grants on leaving an org, linked images follow their repository (#37)
- Repository and site invitations can now be revoked like organization ones: on the
access page, Settings -> People and invitations, GET/DELETE .../invitations in the
REST API, and the MCP tools list_invitations and revoke_invitation.
- Removing someone from an organization removes their (and their agents') grants on
its repositories, including the admin grant on repositories they created.
- An organization image linked to a repository follows that repository's access only,
so with base permission none a member can't pull images of private repositories they
can't see; the registry catalog leaves them out too.
- PATCH /orgs/{org} answers 422 for a non-string name, and the API and settings form
check every field before one write, so a bad base permission doesn't rename.
- Members' agents see default_repository_permission; MCP get_organization exposes it.
- Docs: base permission, revoking invitations, owner-only org pictures, package access.
Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f
Co-authored-by: Claude <noreply@anthropic.com>Canberk Demirci authored · GitHub committed signed1 parent ffe649fcommit 95a6a14ba1131be9ba7694118a689d3544a3a890Browse files
19 files changed+447−96
Files (19)
- .github/workflows/live-test.yml+8−2
- apps/diffinery/docs/accounts/invitations.md+18−4
- apps/diffinery/docs/accounts/organizations.md+24−15
- apps/diffinery/docs/accounts/permissions.md+4−4
- apps/diffinery/docs/agents/mcp.md+3−0
- apps/diffinery/docs/packages/container-registry.md+3−3
- apps/diffinery/src/docs/api-meta.ts+35−0
- apps/diffinery/src/http/api.ts+59−11
- apps/diffinery/src/lib/directory.ts+30−13
- apps/diffinery/src/mcp/server.ts+56−2
- apps/diffinery/src/registry/access.ts+5−3
- apps/diffinery/src/registry/http.ts+3−0
- apps/diffinery/src/web/account.tsx+21−35
- apps/diffinery/src/web/repo-settings.tsx+11−2
- apps/diffinery/src/web/ui.tsx+37−0
- apps/diffinery/test/e2e/e2e.test.mjs+41−0
- apps/diffinery/test/e2e/registry.test.mjs+17−0
- apps/diffinery/test/e2e/security.test.mjs+70−1
- docs/container-registry.md+2−1
modified.github/workflows/live-test.yml+8−2View file
| @@ -270,7 +270,7 @@ | ||
| 270 | 270 | git push -q "https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$REPO.git" main |
| 271 | 271 | git lfs ls-files |
| 272 | 272 | |
| 273 | - - name: Web UI features (highlighting, settings pages, view transitions, base permission) | |
| 273 | + - name: Web UI features (highlighting, settings pages, view transitions, base permission, invitations) | |
| 274 | 274 | if: steps.token.outputs.skip != 'true' |
| 275 | 275 | run: | |
| 276 | 276 | set -euo pipefail |
| @@ -289,8 +289,14 @@ | ||
| 289 | 289 | if [ -n "$org" ]; then |
| 290 | 290 | perm=$(page "/api/v3/orgs/$org" | jq -r '.default_repository_permission // empty') |
| 291 | 291 | case "$perm" in none|read|write|admin) echo "$org's base permission: $perm" ;; *) echo "::error::GET /orgs/$org has no default_repository_permission"; exit 1 ;; esac |
| 292 | + # A malformed rename is refused before anything is written (422 for owners, 403 for everyone else). | |
| 293 | + code=$(curl -sS -o /tmp/out -w '%{http_code}' -X PATCH "$DIFFINERY/api/v3/orgs/$org" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":123}') | |
| 294 | + case "$code" in 422|403) ;; *) echo "::error::PATCH /orgs/$org with a numeric name got $code"; cat /tmp/out; exit 1 ;; esac | |
| 292 | 295 | fi |
| 293 | - echo "highlighting, Appearance, notification settings link, view transitions and visibility checks passed" | |
| 296 | + page "/api/v3/repos/$OWNER/$REPO/invitations" | jq -e 'type == "array"' >/dev/null || { echo "::error::GET /repos/$OWNER/$REPO/invitations doesn't list pending invitations"; exit 1; } | |
| 297 | + tools=$(curl -fsS -X POST "$DIFFINERY/mcp" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" -H "accept: application/json, text/event-stream" --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name') | |
| 298 | + for t in get_organization list_invitations revoke_invitation; do grep -qx "$t" <<<"$tools" || { echo "::error::the MCP server has no $t tool"; exit 1; }; done | |
| 299 | + echo "highlighting, Appearance, notification settings link, view transitions, visibility, organization and invitation checks passed" | |
| 294 | 300 | |
| 295 | 301 | - name: Hosted Actions jobs (and Docker) run in Cloudflare Containers |
| 296 | 302 | if: steps.token.outputs.skip != 'true' |
modifiedapps/diffinery/docs/accounts/invitations.md+18−4View file
| @@ -56,12 +56,16 @@ | ||
| 56 | 56 | |
| 57 | 57 | You can send only one pending invitation per email address and organization from this form. To send a new one, revoke the old one first ("friend@example.com already has a pending invitation; revoke it first to send a new one"). |
| 58 | 58 | |
| 59 | -### Revoke an organization invitation | |
| 59 | +### Revoke an invitation | |
| 60 | 60 | |
| 61 | -On the organization's **People** tab, under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired". The person isn't notified. | |
| 61 | +Every pending invitation can be revoked until it's used: | |
| 62 | 62 | |
| 63 | -Only organization invitations can be revoked. Repository and instance invitations can't be withdrawn; they stop working once used or after 14 days. | |
| 63 | +- organization invitations on the organization's **People** tab (owners and admins); | |
| 64 | +- repository invitations on the repository's **Settings → Collaborators** page (repository admins); | |
| 65 | +- instance invitations on **Settings → People and invitations** (site administrators). | |
| 64 | 66 | |
| 67 | +Under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired". The person isn't notified, and you can invite the same address again. | |
| 68 | + | |
| 65 | 69 | ### Add people to a repository |
| 66 | 70 | |
| 67 | 71 | Repository admins. The **Add people** form on the repository's **Settings → Collaborators** page (`/acme/website/settings/access`) works the same way, and also accepts agents: |
| @@ -146,6 +150,16 @@ | ||
| 146 | 150 | |
| 147 | 151 | `POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only. |
| 148 | 152 | |
| 153 | +### List and revoke pending invitations | |
| 154 | + | |
| 155 | +| Method and path | Who | | |
| 156 | +| --- | --- | | |
| 157 | +| `GET /api/v3/orgs/{org}/invitations`, `DELETE /api/v3/orgs/{org}/invitations/{id}` | The organization's owners and admins | | |
| 158 | +| `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins | | |
| 159 | +| `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) | | |
| 160 | + | |
| 161 | +Each listed invitation has an `id`, `email`, `role`, `inviter`, `created_at` and `expires_at`. `DELETE` returns `204 No Content`, or `404 no pending invitation with this id` when the invitation was already used or revoked, or belongs to another organization, repository or kind. Revoking organization and instance invitations needs an `admin` token; revoking a repository invitation needs admin on the repository. Agents use the MCP tools `list_invitations` and `revoke_invitation` with `owner` and `repo`, `org`, or neither. | |
| 162 | + | |
| 149 | 163 | ### API errors |
| 150 | 164 | |
| 151 | 165 | | Status | Message | Cause | |
| @@ -244,4 +258,4 @@ | ||
| 244 | 258 | - On GitHub anyone can create an account. On Diffinery the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance. |
| 245 | 259 | - GitHub organization and repository invitations go to existing GitHub users. Diffinery invites go to an email address, and the same link creates the account. |
| 246 | 260 | - **Add people** takes a username or an email address, as on GitHub, but someone who already has an account is added immediately, with no invitation to accept. |
| 247 | -- Organization invitations can be revoked from the **People** tab. Repository and instance invitations can't be cancelled; they expire after 14 days. | |
| 261 | +- Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days. |
modifiedapps/diffinery/docs/accounts/organizations.md+24−15View file
| @@ -11,10 +11,10 @@ | ||
| 11 | 11 | |
| 12 | 12 | | Capability | member | admin | owner | |
| 13 | 13 | | --- | --- | --- | --- | |
| 14 | -| Role on every organization repository | write | admin | admin | | |
| 15 | -| See the organization's private repositories | Yes | Yes | Yes | | |
| 16 | -| Create repositories in the organization | Yes | Yes | Yes | | |
| 17 | -| Create container images in the organization and pull its images | Yes | Yes | Yes | | |
| 14 | +| Role on every organization repository | the [base permission](#base-permission) (read by default) | admin | admin | | |
| 15 | +| See the organization's private repositories | Unless the base permission is none | Yes | Yes | | |
| 16 | +| Create repositories in the organization (and administer the ones they create) | Yes | Yes | Yes | | |
| 17 | +| Create container images in the organization and pull its images that aren't linked to a repository | Yes | Yes | Yes | | |
| 18 | 18 | | Push to, delete and change the settings of an organization image that isn't linked to a repository | Only images they created | Yes | Yes | |
| 19 | 19 | | See the **People** and **Agents** tabs | Yes | Yes | Yes | |
| 20 | 20 | | **Add people** (by username or email) | No | Yes (as member or admin) | Yes (any role) | |
| @@ -22,16 +22,21 @@ | ||
| 22 | 22 | | Change roles, remove members | No | Yes, except owners | Yes, including owners | |
| 23 | 23 | | Create and manage the organization's agents | No | Yes | Yes | |
| 24 | 24 | | Manage organization Actions secrets | No | Yes | Yes | |
| 25 | -| Change the organization's picture through the API | No | Yes | Yes | | |
| 26 | -| Open the **Settings** tab: rename, display name, picture | No | No | Yes | | |
| 25 | +| Open the **Settings** tab: rename, display name, base permission, picture | No | No | Yes | | |
| 27 | 26 | | Get emails when repositories are created or deleted in the organization | No | Yes | Yes | |
| 28 | 27 | |
| 29 | 28 | Site administrators can do everything an owner can in every organization, and have admin on every repository. |
| 30 | 29 | |
| 31 | -A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes). See [Container registry](/docs/packages/container-registry). | |
| 30 | +A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes), so a member who can't see a private repository can't pull its images either. See [Container registry](/docs/packages/container-registry). | |
| 32 | 31 | |
| 33 | 32 | A member's role on a repository is the highest of their organization role's repository role and any role given to them directly on that repository. A direct collaborator role can raise an organization member's access (for example to `maintain` or `admin`) but never lowers it. See [Permissions](/docs/accounts/permissions) for what each repository role allows. |
| 34 | 33 | |
| 34 | +### Base permission | |
| 35 | + | |
| 36 | +The base permission is the role every member gets on every repository the organization owns, like GitHub's base permissions: **None**, **Read**, **Write** or **Admin**. New organizations start at **Read**; organizations created before the setting existed keep **Write**. With **None**, members only see the repositories they were given access to and the ones they created. Owners and admins always get admin, and a collaborator role on a repository adds to the base permission. | |
| 37 | + | |
| 38 | +Owners change it on the **Settings** tab under **Base permission**, or with `PATCH /api/v3/orgs/{org}` and `{"default_repository_permission": "write"}`. Members and their agents read it as `default_repository_permission` from `GET /api/v3/orgs/{org}`, or with the MCP tool `get_organization`. | |
| 39 | + | |
| 35 | 40 | ## Create an organization |
| 36 | 41 | |
| 37 | 42 | Any active account can create an organization and becomes its first owner. |
| @@ -126,7 +131,7 @@ | ||
| 126 | 131 | |
| 127 | 132 | ### Remove a member |
| 128 | 133 | |
| 129 | -Click **Remove** in the member's row and confirm "Remove octocat from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Their personal repositories and accounts are not affected. | |
| 134 | +Click **Remove** in the member's row and confirm "Remove octocat from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Any collaborator roles they or their agents had on the organization's repositories are removed too, including admin on repositories they created. Their personal repositories and accounts are not affected. | |
| 130 | 135 | |
| 131 | 136 | Rules: |
| 132 | 137 | |
| @@ -190,7 +195,7 @@ | ||
| 190 | 195 | 2. Click **Save picture**. You see "Organization picture updated." |
| 191 | 196 | 3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed." |
| 192 | 197 | |
| 193 | -Owners and admins (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file: | |
| 198 | +Owners (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file: | |
| 194 | 199 | |
| 195 | 200 | ```sh |
| 196 | 201 | curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \ |
| @@ -201,11 +206,11 @@ | ||
| 201 | 206 | -H "Authorization: Bearer dfy_..." |
| 202 | 207 | ``` |
| 203 | 208 | |
| 204 | -Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners and admins can change its picture` for members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching. | |
| 209 | +Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners can change its picture` for admins and members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching. | |
| 205 | 210 | |
| 206 | 211 | ## Rename an organization |
| 207 | 212 | |
| 208 | -Owners open the **Settings** tab, change **Organization name** or **Display name** and click **Save**. You see "Organization updated." Renaming changes every repository URL in the organization, so existing clones need a new remote URL: | |
| 213 | +Owners open the **Settings** tab, change **Organization name**, **Display name** or **Base permission** and click **Save**. You see "Organization updated." Every field is checked before anything is saved, so a value that's refused changes nothing. Renaming changes every repository URL in the organization, so existing clones need a new remote URL: | |
| 209 | 214 | |
| 210 | 215 | ```sh |
| 211 | 216 | git remote set-url origin https://diffinery.com/acme-inc/website.git |
| @@ -219,24 +224,28 @@ | ||
| 219 | 224 | | --- | --- | |
| 220 | 225 | | `GET /api/v3/user/orgs` | Organizations you belong to, each with your `role` | |
| 221 | 226 | | `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. | |
| 227 | +| `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` | | |
| 228 | +| `PATCH /api/v3/orgs/{org}` | Change `name` or `default_repository_permission` (`none`, `read`, `write` or `admin`). Owners only; needs an `admin` token. | | |
| 222 | 229 | | `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. | |
| 223 | 230 | | `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](/docs/accounts/invitations#invite-with-the-api). | |
| 231 | +| `GET /api/v3/orgs/{org}/invitations` | Pending invitations, each with an `id`. Owners and admins. | | |
| 232 | +| `DELETE /api/v3/orgs/{org}/invitations/{id}` | Revoke a pending invitation; returns `204`. Owners and admins; needs an `admin` token. | | |
| 224 | 233 | | `GET /api/v3/orgs/{org}/repos` | Repositories you can see in the organization | |
| 225 | 234 | | `POST /api/v3/orgs/{org}/repos` | Create a repository in the organization. Needs a `write` or `admin` token. | |
| 226 | 235 | | `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | Organization secrets. Needs an `admin` token. | |
| 227 | -| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners and admins. | | |
| 228 | -| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners and admins. | | |
| 236 | +| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners only. | | |
| 237 | +| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners only. | | |
| 229 | 238 | |
| 230 | 239 | Organization objects in API responses follow GitHub's shape, with `"type": "Organization"`, `html_url` and `avatar_url`. |
| 231 | 240 | |
| 232 | 241 | Every endpoint in this table that changes something is an account-level action: besides the scope noted, the token must not be restricted to repositories, or the request gets `403 this token is limited to repositories and can't change account settings` (the picture endpoints answer `403 changing a profile picture needs a write or admin token that is not limited to repositories`). See [Personal access tokens](/docs/accounts/tokens#account-level-actions). |
| 233 | 242 | |
| 234 | -Adding existing people directly, changing a member's role, removing a member and revoking invitations are done in the web UI. The full list of endpoints is in the [API reference](/docs/api). | |
| 243 | +Adding existing people directly, changing a member's role and removing a member are done in the web UI. The full list of endpoints is in the [API reference](/docs/api). | |
| 235 | 244 | |
| 236 | 245 | ## How organizations differ from GitHub |
| 237 | 246 | |
| 238 | 247 | - Diffinery has three organization roles: owner, admin and member. Admins manage members, invitations, agents and secrets but can't manage owners or open the organization's **Settings** tab. |
| 239 | -- Members get write access to every organization repository. GitHub's default base permission is configurable; Diffinery's is fixed. | |
| 248 | +- The base permission works like GitHub's (none, read, write or admin) and starts at read; organizations created before Diffinery had it keep write. | |
| 240 | 249 | - There are no teams. Give extra access per repository under **Settings → Collaborators**. |
| 241 | 250 | - **Add people** works like GitHub's: enter a username or an email address. Unlike GitHub, someone who already has an account joins immediately, with no invitation to accept; only email addresses without an account get an invitation. |
| 242 | 251 | - Organization pages need you to be signed in. |
modifiedapps/diffinery/docs/accounts/permissions.md+4−4View file
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | - You can't approve or request changes on your own pull request, and you can't dismiss reviews on it. |
| 75 | 75 | - Pull requests opened by agents always need at least one approval from a person. |
| 76 | 76 | |
| 77 | -Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](/docs/packages/container-registry). | |
| 77 | +Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images that aren't linked to a repository, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](/docs/packages/container-registry). | |
| 78 | 78 | |
| 79 | 79 | ## Where your role comes from |
| 80 | 80 | |
| @@ -84,7 +84,7 @@ | ||
| 84 | 84 | | --- | --- | |
| 85 | 85 | | You own the repository (a personal repository) | admin | |
| 86 | 86 | | You are an owner or admin of the organization that owns it | admin | |
| 87 | -| You are a member of the organization that owns it | write | | |
| 87 | +| You are a member of the organization that owns it | the organization's base permission: `read` by default, or `none`, `write` or `admin` as its owners set it | | |
| 88 | 88 | | You were added as a collaborator on the repository (**Settings → Collaborators**, an accepted repository invite, or `PUT /repos/{owner}/{repo}/collaborators/{username}`) | the role you were given | |
| 89 | 89 | | The repository is public | read | |
| 90 | 90 | | You are a site administrator | admin on every repository | |
| @@ -120,7 +120,7 @@ | ||
| 120 | 120 | |
| 121 | 121 | | Organization role | Role on organization repositories | Can also | |
| 122 | 122 | | --- | --- | --- | |
| 123 | -| member | write | Create repositories and container images in the organization | | |
| 123 | +| member | the base permission (`read` by default) | Create repositories (which they administer) and container images in the organization | | |
| 124 | 124 | | admin | admin | Manage members (except owners), invites, agents and organization secrets | |
| 125 | 125 | | owner | admin | Everything an admin can, plus manage owners and the organization's **Settings** tab (name, display name, picture) | |
| 126 | 126 | |
| @@ -218,7 +218,7 @@ | ||
| 218 | 218 | |
| 219 | 219 | - The five repository roles match GitHub's names. Diffinery's `maintain` adds the ability to move or delete tags and to edit other people's comments; repository settings stay with `admin`. |
| 220 | 220 | - Opening a pull request needs `write`: its head branch must be a branch in the same repository. |
| 221 | -- Organization members get `write` on every organization repository, and organization admins get `admin`. There are no teams or custom roles. | |
| 221 | +- Organization members get the organization's base permission on every organization repository (`read` unless its owners change it), and organization admins get `admin`. There are no teams or custom roles. | |
| 222 | 222 | - Personal access tokens have three scopes (`read`, `write`, `admin`) instead of GitHub's fine-grained permissions; the scope caps your role. |
| 223 | 223 | - `GITHUB_TOKEN` in a workflow can read the repository and use the container registry; it can't push commits or call write endpoints. |
| 224 | 224 | - Agents are a separate account type with their own caps and guardrails. |
modifiedapps/diffinery/docs/agents/mcp.md+3−0View file
| @@ -131,6 +131,9 @@ | ||
| 131 | 131 | | --- | --- | --- | --- | |
| 132 | 132 | | `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token | |
| 133 | 133 | | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token | |
| 134 | +| `get_organization` | Yes | An organization's `login` and `name`; to its members, its agents, agents of its members and site administrators also `default_repository_permission` (the base permission members get on its repositories) | Any token | | |
| 135 | +| `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id` | Admin on the repository; organization owner or admin; site administrator | | |
| 136 | +| `revoke_invitation` | No | Revoke a pending invitation by `invitation_id`, with the same `owner` and `repo`, `org` or neither; the address can then be invited again | Same as `list_invitations`; organization and instance invitations need an `admin` token | | |
| 134 | 137 | |
| 135 | 138 | ### Code |
| 136 | 139 |
modifiedapps/diffinery/docs/packages/container-registry.md+3−3View file
| @@ -106,7 +106,7 @@ | ||
| 106 | 106 | |
| 107 | 107 | | Visibility | Who can pull | |
| 108 | 108 | | --- | --- | |
| 109 | -| **Private** (default) | The owning user, the members of the owning organization, and people with access to the linked repository. See [Permissions](#permissions). | | |
| 109 | +| **Private** (default) | The owning user, people with access to the linked repository, and, for a package that isn't linked to a repository, the members of the owning organization. See [Permissions](#permissions). | | |
| 110 | 110 | | **Public** | Anyone, without signing in. | |
| 111 | 111 | |
| 112 | 112 | There are only these two settings. Visibility only controls pulling. Pushing and deleting always need the permissions described below. |
| @@ -175,7 +175,7 @@ | ||
| 175 | 175 | | --- | --- | |
| 176 | 176 | | The package belongs to your user account | Admin | |
| 177 | 177 | | You're an owner or admin of the organization that owns the package | Admin | |
| 178 | -| You're a member of the organization that owns the package | Pull, and you can create new packages under the organization | | |
| 178 | +| You're a member of the organization that owns the package | Pull on packages that aren't linked to a repository (a linked one follows your role on its repository), and you can create new packages under the organization | | |
| 179 | 179 | | You're the organization member who created the package (or whose agent did), and it isn't linked to a repository | Admin | |
| 180 | 180 | | The package is linked to a repository where you have `read` | Pull | |
| 181 | 181 | | The package is linked to a repository where you have `write` or `maintain` | Push | |
| @@ -194,7 +194,7 @@ | ||
| 194 | 194 | | Owned by the user that owns the package | Push | |
| 195 | 195 | | Owned by the organization that owns the package | Push | |
| 196 | 196 | | Owned by an owner or admin of the organization that owns the package | Push | |
| 197 | -| Owned by a member of the organization that owns the package | Pull, and it can create new packages under the organization. Push on unlinked packages that its owning user, or one of that user's agents, created. | | |
| 197 | +| Owned by a member of the organization that owns the package | Pull on packages that aren't linked to a repository, and it can create new packages under the organization. Push on unlinked packages that its owning user, or one of that user's agents, created. | | |
| 198 | 198 | |
| 199 | 199 | Linked repositories give an agent the same roles as a person, based on the agent's own access to the repository, so an agent with `admin` on the linked repository administers the package. |
| 200 | 200 |
modifiedapps/diffinery/src/docs/api-meta.ts+35−0View file
| @@ -35,6 +35,11 @@ | ||
| 35 | 35 | "POST /user/orgs": { scope: "admin" }, |
| 36 | 36 | "POST /orgs/{org}/invitations": { scope: "admin" }, |
| 37 | 37 | "POST /invitations": { scope: "admin" }, |
| 38 | + "PATCH /orgs/{org}": { scope: "admin" }, | |
| 39 | + "GET /orgs/{org}/invitations": { scope: "read" }, | |
| 40 | + "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" }, | |
| 41 | + "GET /invitations": { scope: "read" }, | |
| 42 | + "DELETE /invitations/{id}": { scope: "admin" }, | |
| 38 | 43 | "POST /user/repos": { scope: "write" }, |
| 39 | 44 | "POST /orgs/{org}/repos": { scope: "write" }, |
| 40 | 45 | "GET /orgs/{org}/actions/secrets": { scope: "admin" }, |
| @@ -85,6 +90,20 @@ | ||
| 85 | 90 | status: 201, |
| 86 | 91 | body: { login: ["string", "Organization login (letters, digits and dashes).", true], name: ["string", "Display name (default: the login)."] }, |
| 87 | 92 | }, |
| 93 | + "GET /orgs/{org}": { | |
| 94 | + summary: "Get an organization", | |
| 95 | + description: "Members, the organization's agents, agents of members, and site admins also see `default_repository_permission`: the base permission every member gets on its repositories.", | |
| 96 | + access: "account", | |
| 97 | + }, | |
| 98 | + "PATCH /orgs/{org}": { | |
| 99 | + summary: "Update an organization", | |
| 100 | + description: "Owners only (or site admins). Every field is checked before anything is saved, so a bad value changes nothing. Audited.", | |
| 101 | + access: "org-admin", | |
| 102 | + body: { | |
| 103 | + name: ["string", "Display name; blank goes back to the login."], | |
| 104 | + default_repository_permission: ["string", "Base permission members get on every repository: `none`, `read`, `write` or `admin`. Owners and admins always get admin, and per-repository grants add to it."], | |
| 105 | + }, | |
| 106 | + }, | |
| 88 | 107 | "GET /orgs/{org}/members": { summary: "List organization members", description: "Members only (or site admins). Each entry carries the member's `role`.", access: "account" }, |
| 89 | 108 | "POST /orgs/{org}/invitations": { |
| 90 | 109 | summary: "Invite someone to an organization", |
| @@ -93,6 +112,13 @@ | ||
| 93 | 112 | status: 201, |
| 94 | 113 | body: { email: ["string", "Email address to invite.", true], role: ["string", "`member` (default), `admin` or `owner`."] }, |
| 95 | 114 | }, |
| 115 | + "GET /orgs/{org}/invitations": { summary: "List an organization's pending invitations", description: "Owners and admins (or site admins). Each has an `id` to revoke it with.", access: "org-admin" }, | |
| 116 | + "DELETE /orgs/{org}/invitations/{id}": { | |
| 117 | + summary: "Revoke an organization invitation", | |
| 118 | + description: "The link stops working and the address can be invited again. 404 when no pending invitation has this id. Audited.", | |
| 119 | + access: "org-admin", | |
| 120 | + status: 204, | |
| 121 | + }, | |
| 96 | 122 | "PUT /orgs/{org}/avatar": { |
| 97 | 123 | summary: "Set an organization's picture", |
| 98 | 124 | description: |
| @@ -107,6 +133,8 @@ | ||
| 107 | 133 | status: 201, |
| 108 | 134 | body: { email: ["string", "Email address to invite.", true] }, |
| 109 | 135 | }, |
| 136 | + "GET /invitations": { summary: "List pending invitations to this instance", description: "Site-wide invites only, not those to an organization or repository. Site admins only.", access: "site-admin" }, | |
| 137 | + "DELETE /invitations/{id}": { summary: "Revoke an invitation to this instance", description: "Site admins only. 404 when no pending site-wide invitation has this id. Audited.", access: "site-admin", status: 204 }, | |
| 110 | 138 | |
| 111 | 139 | // ------------------------------------------------------------ search |
| 112 | 140 | "GET /search/issues": { |
| @@ -182,6 +210,13 @@ | ||
| 182 | 210 | body: { permission: ["string", "`read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too."] }, |
| 183 | 211 | }, |
| 184 | 212 | "DELETE /repos/{owner}/{repo}/collaborators/{login}": { summary: "Remove a collaborator", access: "admin", status: 204 }, |
| 213 | + "GET /repos/{owner}/{repo}/invitations": { summary: "List pending repository invitations", description: "Invitations sent by email to people without an account. Each has an `id` to revoke it with.", access: "admin" }, | |
| 214 | + "DELETE /repos/{owner}/{repo}/invitations/{id}": { | |
| 215 | + summary: "Revoke a repository invitation", | |
| 216 | + description: "The link stops working and the address can be invited again. 404 when no pending invitation to this repository has this id. Audited.", | |
| 217 | + access: "admin", | |
| 218 | + status: 204, | |
| 219 | + }, | |
| 185 | 220 | "GET /repos/{owner}/{repo}/assignees": { summary: "List assignable people", description: "Collaborators with triage access or more, plus the owner when the owner is a user.", access: "read", query: { ...page } }, |
| 186 | 221 | "GET /repos/{owner}/{repo}/assignees/{login}": { |
| 187 | 222 | summary: "Check if someone can be assigned", |
modifiedapps/diffinery/src/http/api.ts+59−11View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { fromUtf8 } from "@diffinery/git-core"; |
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | 4 | import { callerFor, parseScopes, requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js"; |
| 5 | -import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 5 | +import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| 8 | 8 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| @@ -73,14 +73,13 @@ | ||
| 73 | 73 | api.post("/user/orgs", async (c) => { |
| 74 | 74 | requireAccountScope(c, "admin"); |
| 75 | 75 | const a = requireAccount(c); |
| 76 | - const b = await body<{ login: string; name?: string }>(c); | |
| 77 | - return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201); | |
| 76 | + const b = await body<{ login: string; name?: unknown }>(c); | |
| 77 | + if (typeof b.login !== "string") throw new HttpError(422, "login is required", { errors: [{ resource: "Organization", code: "missing_field", field: "login" }] }); | |
| 78 | + if (b.name !== undefined && b.name !== null && typeof b.name !== "string") throw new HttpError(422, "name must be a string", { errors: [{ resource: "Organization", code: "invalid", field: "name" }] }); | |
| 79 | + return c.json(userJson(c, await c.get("dir").createOrg(b.login, (b.name as string | null | undefined) || b.login, a)), 201); | |
| 78 | 80 | }); |
| 79 | -// GitHub's organization object; members also see the base permission (default_repository_permission). | |
| 80 | -const orgJson = async (c: AppContext, org: Account, me: Account) => { | |
| 81 | - const role = await c.get("dir").orgRole(org.id, me.id); | |
| 82 | - return { ...userJson(c, org), ...(role || me.site_admin ? { default_repository_permission: baseRole(org) } : {}) }; | |
| 83 | -}; | |
| 81 | +// GitHub's organization object; members and their agents also see the base permission (default_repository_permission). | |
| 82 | +const orgJson = async (c: AppContext, org: Account, me: Account) => ({ ...userJson(c, org), ...((await c.get("dir").inOrg(org.id, me)) ? { default_repository_permission: baseRole(org) } : {}) }); | |
| 84 | 83 | api.get("/orgs/:org", async (c) => { |
| 85 | 84 | const a = requireAccount(c); |
| 86 | 85 | const org = await c.get("dir").accountByLogin(c.req.param("org")); |
| @@ -96,9 +95,8 @@ | ||
| 96 | 95 | const role = await dir.orgRole(org.id, a.id); |
| 97 | 96 | if (!role && !a.site_admin) throw new HttpError(404, "not found"); |
| 98 | 97 | if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can change its settings"); |
| 99 | - const b = await body<{ name?: string; default_repository_permission?: string }>(c); | |
| 100 | - if (b.default_repository_permission !== undefined) await dir.setBaseRole(org.id, b.default_repository_permission); | |
| 101 | - if (b.name !== undefined) await dir.updateAccount(org.id, { name: b.name }); | |
| 98 | + const b = await body<{ name?: unknown; default_repository_permission?: unknown }>(c); | |
| 99 | + await dir.updateAccount(org.id, { name: b.name, baseRole: b.default_repository_permission }); | |
| 102 | 100 | c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.update", target: org.login, detail: { name: b.name, base_role: b.default_repository_permission } })); |
| 103 | 101 | return c.json(await orgJson(c, (await dir.account(org.id))!, a)); |
| 104 | 102 | }); |
| @@ -138,6 +136,56 @@ | ||
| 138 | 136 | return c.json({ id: inv.id, url: inv.url }, 201); |
| 139 | 137 | }); |
| 140 | 138 | |
| 139 | +// Pending invitations: list and revoke, for an organization's owners and admins, a repository's admins, | |
| 140 | +// and site admins (invitations to the site itself). | |
| 141 | +const inviteJson = (i: Awaited<ReturnType<Directory["invites"]>>[number]) => ({ id: i.id, email: i.email, role: i.role, inviter: i.invitedBy ? { login: i.invitedBy } : null, created_at: i.createdAt, expires_at: i.expiresAt }); | |
| 142 | +const orgForInvites = async (c: AppContext) => { | |
| 143 | + requireAccountScope(c, "read"); | |
| 144 | + const a = requireAccount(c); | |
| 145 | + const dir = c.get("dir"); | |
| 146 | + const org = await dir.accountByLogin(c.req.param("org") ?? ""); | |
| 147 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 148 | + const role = await dir.orgRole(org.id, a.id); | |
| 149 | + if (!role && !a.site_admin) throw new HttpError(404, "not found"); | |
| 150 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can manage invitations"); | |
| 151 | + return { a, dir, org }; | |
| 152 | +}; | |
| 153 | +const revoked = async (c: AppContext, ok: boolean, action: string, target?: string) => { | |
| 154 | + if (!ok) throw new HttpError(404, "no pending invitation with this id"); | |
| 155 | + const a = requireAccount(c); | |
| 156 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action, target, detail: { id: c.req.param("id"), via: "api" } })); | |
| 157 | + return c.body(null, 204); | |
| 158 | +}; | |
| 159 | +api.get("/orgs/:org/invitations", async (c) => { | |
| 160 | + const { dir, org } = await orgForInvites(c); | |
| 161 | + return c.json((await dir.invites({ orgId: org.id, pending: true })).map(inviteJson)); | |
| 162 | +}); | |
| 163 | +api.delete("/orgs/:org/invitations/:id", async (c) => { | |
| 164 | + requireAccountScope(c, "admin"); | |
| 165 | + const { dir, org } = await orgForInvites(c); | |
| 166 | + return revoked(c, await dir.revokeInvite(c.req.param("id"), { orgId: org.id }), "org.invite.revoke", org.login); | |
| 167 | +}); | |
| 168 | +api.get("/repos/:owner/:repo/invitations", async (c) => { | |
| 169 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 170 | + return c.json((await c.get("dir").invites({ repoId: repo.id, pending: true })).map(inviteJson)); | |
| 171 | +}); | |
| 172 | +api.delete("/repos/:owner/:repo/invitations/:id", async (c) => { | |
| 173 | + const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 174 | + if (!(await c.get("dir").revokeInvite(c.req.param("id"), { repoId: repo.id }))) throw new HttpError(404, "no pending invitation with this id"); | |
| 175 | + auditRepo(c, caller, repo, "repo.invite.revoke", { id: c.req.param("id") }); | |
| 176 | + return c.body(null, 204); | |
| 177 | +}); | |
| 178 | +api.get("/invitations", async (c) => { | |
| 179 | + requireAccountScope(c, "read"); | |
| 180 | + if (!requireAccount(c).site_admin) throw new HttpError(403, "only site admins can list site invitations"); | |
| 181 | + return c.json((await c.get("dir").invites({ site: true, pending: true })).map(inviteJson)); | |
| 182 | +}); | |
| 183 | +api.delete("/invitations/:id", async (c) => { | |
| 184 | + requireAccountScope(c, "admin"); | |
| 185 | + if (!requireAccount(c).site_admin) throw new HttpError(403, "only site admins can revoke site invitations"); | |
| 186 | + return revoked(c, await c.get("dir").revokeInvite(c.req.param("id"), {}), "invite.revoke"); | |
| 187 | +}); | |
| 188 | + | |
| 141 | 189 | // ---------------------------------------------------------------- repositories |
| 142 | 190 | |
| 143 | 191 | api.get("/user/repos", async (c) => c.json(paginate(c, (await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))))); |
modifiedapps/diffinery/src/lib/directory.ts+30−13View file
| @@ -119,17 +119,20 @@ | ||
| 119 | 119 | return (await this.account(id))!; |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | - async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> { | |
| 122 | + /** Rename an account and change its display name (and an organization's base permission) in one write, after checking every field. */ | |
| 123 | + async updateAccount(id: string, patch: { login?: string; name?: unknown; baseRole?: unknown }): Promise<void> { | |
| 124 | + if (patch.name !== undefined && patch.name !== null && typeof patch.name !== "string") throw new HttpError(422, "name must be a string", { errors: [{ resource: "Account", code: "invalid", field: "name" }] }); | |
| 125 | + if (patch.baseRole !== undefined && !BASE_ROLES.includes(patch.baseRole as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] }); | |
| 123 | 126 | if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login"); |
| 124 | 127 | if (patch.login) { |
| 125 | 128 | const clash = await this.accountByLogin(patch.login); |
| 126 | 129 | if (clash && clash.id !== id) throw new HttpError(422, "login already taken"); |
| 127 | 130 | } |
| 128 | 131 | // A blank display name clears it, so pages fall back to the login. |
| 129 | - const name = patch.name === undefined ? undefined : patch.name.trim() || null; | |
| 132 | + const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null; | |
| 130 | 133 | await this.db |
| 131 | - .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END WHERE id = ?") | |
| 132 | - .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, id) | |
| 134 | + .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END, base_role = CASE WHEN kind = 'org' THEN COALESCE(?, base_role) ELSE base_role END WHERE id = ?") | |
| 135 | + .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, (patch.baseRole as string | undefined) ?? null, id) | |
| 133 | 136 | .run(); |
| 134 | 137 | if (patch.login) { |
| 135 | 138 | // Repos keep their ids; their Durable Objects cache the owner login, so refresh them. |
| @@ -159,9 +162,12 @@ | ||
| 159 | 162 | return (await this.account(id))!; |
| 160 | 163 | } |
| 161 | 164 | |
| 162 | - async setBaseRole(orgId: string, role: string): Promise<void> { | |
| 163 | - if (!BASE_ROLES.includes(role as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] }); | |
| 164 | - await this.db.prepare("UPDATE accounts SET base_role = ? WHERE id = ? AND kind = 'org'").bind(role, orgId).run(); | |
| 165 | + /** Whether an account sees an organization's settings such as its base permission: members, the | |
| 166 | + * organization's own agents, agents of members, and site admins. */ | |
| 167 | + async inOrg(orgId: string, account: Account): Promise<boolean> { | |
| 168 | + if (account.site_admin) return true; | |
| 169 | + if (account.kind === "agent") return account.owner_id === orgId || (!!account.owner_id && !!(await this.orgRole(orgId, account.owner_id))); | |
| 170 | + return !!(await this.orgRole(orgId, account.id)); | |
| 165 | 171 | } |
| 166 | 172 | |
| 167 | 173 | async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> { |
| @@ -236,8 +242,13 @@ | ||
| 236 | 242 | return { invited: { email: q, url: inv.url } }; |
| 237 | 243 | } |
| 238 | 244 | |
| 239 | - async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<void> { | |
| 240 | - await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND (org_id IS ? OR ? IS NULL) AND (repo_id IS ? OR ? IS NULL)").bind(id, scope.orgId ?? null, scope.orgId ?? null, scope.repoId ?? null, scope.repoId ?? null).run(); | |
| 245 | + /** Revoke a pending invitation to an organization, a repository, or (neither) the site. False if there was none. */ | |
| 246 | + async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<boolean> { | |
| 247 | + const r = await this.db | |
| 248 | + .prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND org_id IS ? AND repo_id IS ?") | |
| 249 | + .bind(id, scope.orgId ?? null, scope.repoId ?? null) | |
| 250 | + .run(); | |
| 251 | + return (r.meta?.changes ?? 0) > 0; | |
| 241 | 252 | } |
| 242 | 253 | |
| 243 | 254 | private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { |
| @@ -247,8 +258,14 @@ | ||
| 247 | 258 | if (!owners?.n) throw new HttpError(422, "an organization needs at least one owner; make someone else an owner first"); |
| 248 | 259 | } |
| 249 | 260 | if (role === null) { |
| 250 | - await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run(); | |
| 251 | - // Removing someone revokes their tokens' reach immediately: roles are computed per request. | |
| 261 | + // Their grants on the organization's repositories (including the admin grant for repositories they | |
| 262 | + // created) go too, for them and their agents. Roles are computed per request, so tokens lose access at once. | |
| 263 | + await this.db.batch([ | |
| 264 | + this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId), | |
| 265 | + this.db | |
| 266 | + .prepare("DELETE FROM repo_access WHERE repo_id IN (SELECT id FROM repos WHERE owner_id = ?) AND account_id IN (SELECT ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ?)") | |
| 267 | + .bind(orgId, userId, userId), | |
| 268 | + ]); | |
| 252 | 269 | return; |
| 253 | 270 | } |
| 254 | 271 | await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run(); |
| @@ -383,10 +400,10 @@ | ||
| 383 | 400 | return { id, url, token }; |
| 384 | 401 | } |
| 385 | 402 | |
| 386 | - async invites(filter: { orgId?: string; repoId?: string; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> { | |
| 403 | + async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> { | |
| 387 | 404 | const r = await this.db |
| 388 | 405 | .prepare( |
| 389 | - `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`, | |
| 406 | + `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.site ? " AND i.org_id IS NULL AND i.repo_id IS NULL" : ""}${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`, | |
| 390 | 407 | ) |
| 391 | 408 | .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : [])) |
| 392 | 409 | .all<Record<string, string | null>>(); |
modifiedapps/diffinery/src/mcp/server.ts+56−2View file
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | -import { requireAccount, requireScope, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 3 | +import { requireAccount, requireAccountScope, requireScope, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | 4 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 5 | 5 | import type { Role } from "../repo/types.js"; |
| 6 | -import type { TokenScope } from "../lib/directory.js"; | |
| 6 | +import { baseRole, type TokenScope } from "../lib/directory.js"; | |
| 7 | 7 | import { loadRepo } from "../http/common.js"; |
| 8 | 8 | import { RELEASE_TOOLS } from "./releases.js"; |
| 9 | 9 | import { DOCS_TOOLS } from "./docs.js"; |
| @@ -328,6 +328,42 @@ | ||
| 328 | 328 | return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller); |
| 329 | 329 | }, |
| 330 | 330 | }, |
| 331 | + get_organization: { | |
| 332 | + description: "Show an organization: its name and, to its members and their agents, the base permission every member gets on its repositories (default_repository_permission: none, read, write or admin).", | |
| 333 | + input: { org: { type: "string", description: "Organization login" } }, | |
| 334 | + required: ["org"], | |
| 335 | + readOnly: true, | |
| 336 | + run: async (c, a) => { | |
| 337 | + const me = requireAccount(c); | |
| 338 | + const dir = c.get("dir"); | |
| 339 | + const org = await dir.accountByLogin(str(a, "org")); | |
| 340 | + if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); | |
| 341 | + return { login: org.login, name: org.name || null, ...((await dir.inOrg(org.id, me)) ? { default_repository_permission: baseRole(org) } : {}) }; | |
| 342 | + }, | |
| 343 | + }, | |
| 344 | + list_invitations: { | |
| 345 | + description: "List pending invitations to a repository (owner and repo, for its admins), an organization (org, for its owners and admins), or the site (neither, for site admins).", | |
| 346 | + input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" } }, | |
| 347 | + readOnly: true, | |
| 348 | + run: async (c, a) => { | |
| 349 | + const scope = await inviteScope(c, a, "read"); | |
| 350 | + return (await c.get("dir").invites({ ...scope, site: !scope.orgId && !scope.repoId, pending: true })).map((i) => ({ id: i.id, email: i.email, role: i.role, inviter: i.invitedBy, created_at: i.createdAt, expires_at: i.expiresAt })); | |
| 351 | + }, | |
| 352 | + }, | |
| 353 | + revoke_invitation: { | |
| 354 | + description: "Revoke a pending invitation by id (from list_invitations), so it can't be accepted and the address can be invited again. Takes the same owner and repo, org, or neither as list_invitations.", | |
| 355 | + input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" }, invitation_id: { type: "string" } }, | |
| 356 | + required: ["invitation_id"], | |
| 357 | + run: async (c, a) => { | |
| 358 | + const scope = await inviteScope(c, a, "admin"); | |
| 359 | + const dir = c.get("dir"); | |
| 360 | + const id = str(a, "invitation_id"); | |
| 361 | + if (!(await dir.revokeInvite(id, scope))) throw new HttpError(404, "no pending invitation with this id"); | |
| 362 | + const me = requireAccount(c); | |
| 363 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: scope.repoId ? "repo.invite.revoke" : scope.orgId ? "org.invite.revoke" : "invite.revoke", target: optStr(a, "org") ?? (scope.repoId ? `${optStr(a, "owner")}/${optStr(a, "repo")}` : undefined), detail: { id } })); | |
| 364 | + return { revoked: true }; | |
| 365 | + }, | |
| 366 | + }, | |
| 331 | 367 | sandbox_create: { |
| 332 | 368 | description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.", |
| 333 | 369 | input: { ...OWNER_REPO, ref: { type: "string" } }, |
| @@ -435,6 +471,24 @@ | ||
| 435 | 471 | return p.type === "account" ? p.scopes : []; |
| 436 | 472 | } |
| 437 | 473 | |
| 474 | +/** Which invitations a caller may manage: a repository's (admins), an organization's (owners and admins) or the site's (site admins). */ | |
| 475 | +async function inviteScope(c: AppContext, a: Args, need: "read" | "admin"): Promise<{ orgId?: string; repoId?: string }> { | |
| 476 | + if (optStr(a, "owner") || optStr(a, "repo")) return { repoId: (await repoOf(c, a, "admin")).repo.id }; | |
| 477 | + // Organization and site invitations are account settings, as in the REST API. | |
| 478 | + requireAccountScope(c, need); | |
| 479 | + const me = requireAccount(c); | |
| 480 | + const dir = c.get("dir"); | |
| 481 | + if (optStr(a, "org")) { | |
| 482 | + const org = await dir.accountByLogin(str(a, "org")); | |
| 483 | + if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); | |
| 484 | + const role = me.kind === "agent" ? (me.owner_id ? await dir.orgRole(org.id, me.owner_id) : null) : await dir.orgRole(org.id, me.id); | |
| 485 | + if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can manage its invitations"); | |
| 486 | + return { orgId: org.id }; | |
| 487 | + } | |
| 488 | + if (!me.site_admin) throw new HttpError(403, "only site admins can manage invitations to the site; pass owner and repo, or org"); | |
| 489 | + return {}; | |
| 490 | +} | |
| 491 | + | |
| 438 | 492 | async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) { |
| 439 | 493 | const { stub, caller } = await repoOf(c, a, "write"); |
| 440 | 494 | const id = str(a, "sandbox_id"); |
modifiedapps/diffinery/src/registry/access.ts+5−3View file
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * - A package linked to a repository shares its permissions: readers pull, writers push, |
| 11 | 11 | * admins delete. The owner (the user, or the organization's owners and admins) always |
| 12 | 12 | * administers its packages. Organization members can create packages and pull the |
| 13 | - * organization's packages; an unlinked package is administered by whoever created it. | |
| 13 | + * organization's unlinked packages; an unlinked package is administered by whoever created it. | |
| 14 | 14 | * - Packages are private unless made public. |
| 15 | 15 | * - A workflow's GITHUB_TOKEN follows the job's `packages` permission. It pulls packages |
| 16 | 16 | * of its own owner, pushes to its repository's packages (creating them, linked to the |
| @@ -82,7 +82,7 @@ | ||
| 82 | 82 | const subject = a.kind === "agent" ? a.owner_id : a.id; |
| 83 | 83 | |
| 84 | 84 | // The owner administers its packages. Organization members create packages and pull the |
| 85 | - // organization's; an unlinked package is administered by whoever created it (like ghcr.io, | |
| 85 | + // organization's unlinked ones; an unlinked package is administered by whoever created it (like ghcr.io, | |
| 86 | 86 | // membership alone doesn't let one member push to another's package). |
| 87 | 87 | let role: PackageRole = "none"; |
| 88 | 88 | if (a.site_admin) role = "admin"; |
| @@ -96,7 +96,9 @@ | ||
| 96 | 96 | // The creator, or the member whose agent created it. |
| 97 | 97 | let creator = !!pkg?.created_by && (pkg.created_by === a.id || pkg.created_by === subject); |
| 98 | 98 | if (pkg?.created_by && !pkg.repo_id && !creator) creator = (await dir.account(pkg.created_by))?.owner_id === subject; |
| 99 | - role = !pkg ? "push" : !pkg.repo_id && creator ? (a.kind === "agent" ? "push" : "admin") : "pull"; | |
| 99 | + // A package linked to a repository follows that repository's access alone (below), so | |
| 100 | + // a member without access to a private repository can't pull its images either. | |
| 101 | + role = !pkg ? "push" : pkg.repo_id ? "none" : creator ? (a.kind === "agent" ? "push" : "admin") : "pull"; | |
| 100 | 102 | } |
| 101 | 103 | } |
| 102 | 104 | } |
modifiedapps/diffinery/src/registry/http.ts+3−0View file
| @@ -564,6 +564,9 @@ | ||
| 564 | 564 | const orgs = (await dir.orgsFor(subject)).map((o) => o.id); |
| 565 | 565 | const repos = (await dir.reposVisibleTo(p.account)).map((r) => r.id); |
| 566 | 566 | rows = await store.packages({ ownerIds: p.repoIds ? [] : [subject, ...orgs], repoIds: p.repoIds ?? repos, limit: 5000 }); |
| 567 | + // An organization's packages linked to a repository are listed only to those who can see it. | |
| 568 | + const seen = new Set(repos); | |
| 569 | + rows = rows.filter((r) => r.owner_id === subject || !r.repo_id || seen.has(r.repo_id)); | |
| 567 | 570 | } else { |
| 568 | 571 | const repo = await dir.repoById(p.repoId); |
| 569 | 572 | rows = repo ? await store.packages({ ownerIds: [repo.owner_id], limit: 5000 }) : []; |
modifiedapps/diffinery/src/web/account.tsx+21−35View file
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | import { repoCreated } from "../mail/notify.js"; |
| 14 | 14 | import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; |
| 15 | 15 | import { deleteCookie, setCookie } from "hono/cookie"; |
| 16 | -import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 16 | +import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, PendingInvites, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 17 | 17 | import type { FormData } from "./csrf.js"; |
| 18 | 18 | import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js"; |
| 19 | 19 | import { MAX_AVATAR_BYTES } from "../lib/image.js"; |
| @@ -1285,7 +1285,7 @@ | ||
| 1285 | 1285 | const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => { |
| 1286 | 1286 | const dir = c.get("dir"); |
| 1287 | 1287 | const t = csrfToken(c); |
| 1288 | - const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]); | |
| 1288 | + const [users, requests, invites] = await Promise.all([dir.users(), dir.accessRequests(), dir.invites({ site: true, pending: true })]); | |
| 1289 | 1289 | return page( |
| 1290 | 1290 | c, |
| 1291 | 1291 | me, |
| @@ -1349,6 +1349,7 @@ | ||
| 1349 | 1349 | <div class="hint">We email them a link. Opening it lets them pick a username and create a passkey.</div> |
| 1350 | 1350 | </div> |
| 1351 | 1351 | </Form> |
| 1352 | + <PendingInvites invites={invites} revoke={(id) => `/settings/admin/invites/${id}/revoke`} t={t} roles={false} /> | |
| 1352 | 1353 | <div class="box"> |
| 1353 | 1354 | <div class="head"> |
| 1354 | 1355 | <h2>Users</h2> |
| @@ -1437,6 +1438,20 @@ | ||
| 1437 | 1438 | return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: `We emailed it to ${email}; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey.` }); |
| 1438 | 1439 | }); |
| 1439 | 1440 | |
| 1441 | + post(web, "/settings/admin/invites/:id/revoke", async (c) => { | |
| 1442 | + const me = requireSiteAdmin(c); | |
| 1443 | + const dir = c.get("dir"); | |
| 1444 | + return act( | |
| 1445 | + c, | |
| 1446 | + "/settings/admin", | |
| 1447 | + async () => { | |
| 1448 | + if (!(await dir.revokeInvite(prm(c, "id"), {}))) throw new HttpError(404, "this invitation was already used, revoked or never existed"); | |
| 1449 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "invite.revoke", detail: { id: prm(c, "id") } })); | |
| 1450 | + }, | |
| 1451 | + "Invitation revoked.", | |
| 1452 | + ); | |
| 1453 | + }); | |
| 1454 | + | |
| 1440 | 1455 | web.get("/settings/audit", async (c) => { |
| 1441 | 1456 | const me = requireSiteAdmin(c); |
| 1442 | 1457 | const rows = await c.get("dir").auditLog(200); |
| @@ -1760,36 +1775,7 @@ | ||
| 1760 | 1775 | </tbody> |
| 1761 | 1776 | </table> |
| 1762 | 1777 | </div> |
| 1763 | - {invites.length ? ( | |
| 1764 | - <div class="box"> | |
| 1765 | - <div class="head"> | |
| 1766 | - <h3>{raw(ICONS.envelope)} Pending invitations</h3> | |
| 1767 | - </div> | |
| 1768 | - <table class="tbl"> | |
| 1769 | - <tbody> | |
| 1770 | - {invites.map((i) => ( | |
| 1771 | - <tr> | |
| 1772 | - <td> | |
| 1773 | - <b>{i.email}</b> | |
| 1774 | - <div class="muted small"> | |
| 1775 | - Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""} | |
| 1776 | - <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })} | |
| 1777 | - </div> | |
| 1778 | - </td> | |
| 1779 | - <td> | |
| 1780 | - <span class="badge">{ROLE_LABEL[i.role] ?? i.role}</span> | |
| 1781 | - </td> | |
| 1782 | - <td class="right"> | |
| 1783 | - <Form action={`${base}/-/org/invites/${i.id}/revoke`} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}> | |
| 1784 | - <button class="btn sm danger">Revoke</button> | |
| 1785 | - </Form> | |
| 1786 | - </td> | |
| 1787 | - </tr> | |
| 1788 | - ))} | |
| 1789 | - </tbody> | |
| 1790 | - </table> | |
| 1791 | - </div> | |
| 1792 | - ) : null} | |
| 1778 | + <PendingInvites invites={invites} revoke={(id) => `${base}/-/org/invites/${id}/revoke`} t={t} /> | |
| 1793 | 1779 | </> |
| 1794 | 1780 | ); |
| 1795 | 1781 | } else if (tab === "agents") { |
| @@ -1892,7 +1878,7 @@ | ||
| 1892 | 1878 | `/${org.login}?tab=people`, |
| 1893 | 1879 | async () => { |
| 1894 | 1880 | if (!isAdmin) throw new HttpError(403, "organization admins only"); |
| 1895 | - await dir.revokeInvite(prm(c, "id"), { orgId: org.id }); | |
| 1881 | + if (!(await dir.revokeInvite(prm(c, "id"), { orgId: org.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed"); | |
| 1896 | 1882 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite.revoke", target: org.login, detail: { id: prm(c, "id") } })); |
| 1897 | 1883 | }, |
| 1898 | 1884 | "Invitation revoked.", |
| @@ -1921,8 +1907,8 @@ | ||
| 1921 | 1907 | return act(c, `/${org.login}?tab=settings`, async () => { |
| 1922 | 1908 | if (!isOwner) throw new HttpError(403, "organization owners only"); |
| 1923 | 1909 | const login = f.s("login").trim(); |
| 1924 | - await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() }); | |
| 1925 | - if (f.s("base_role")) await dir.setBaseRole(org.id, f.s("base_role")); | |
| 1910 | + // One validated write: a bad base permission doesn't leave a half-applied rename behind. | |
| 1911 | + await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim(), baseRole: f.s("base_role") || undefined }); | |
| 1926 | 1912 | if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version])); |
| 1927 | 1913 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login, base_role: f.s("base_role") || undefined } })); |
| 1928 | 1914 | return `/${login || org.login}?tab=settings`; |
modifiedapps/diffinery/src/web/repo-settings.tsx+11−2View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { ICONS } from "./assets.js"; |
| 9 | 9 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 10 | 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | -import { Actor, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 11 | +import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 12 | 12 | |
| 13 | 13 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 14 | 14 | type Tab = "general" | "access" | "branches" | "secrets" | "webhooks"; |
| @@ -262,7 +262,7 @@ | ||
| 262 | 262 | const { repo } = l; |
| 263 | 263 | const base = repoUrl(repo); |
| 264 | 264 | const t = csrfToken(c); |
| 265 | - const collaborators = await c.get("dir").collaborators(repo.id); | |
| 265 | + const [collaborators, invites] = await Promise.all([c.get("dir").collaborators(repo.id), c.get("dir").invites({ repoId: repo.id, pending: true })]); | |
| 266 | 266 | return page( |
| 267 | 267 | c, |
| 268 | 268 | l, |
| @@ -338,6 +338,7 @@ | ||
| 338 | 338 | <div class="empty">No direct collaborators.</div> |
| 339 | 339 | )} |
| 340 | 340 | </div> |
| 341 | + <PendingInvites invites={invites} revoke={(id) => `${base}/settings/access/invites/${id}/revoke`} t={t} /> | |
| 341 | 342 | <div class="box"> |
| 342 | 343 | <div class="body small muted"> |
| 343 | 344 | <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access. |
| @@ -645,6 +646,14 @@ | ||
| 645 | 646 | }); |
| 646 | 647 | }); |
| 647 | 648 | |
| 649 | + post(web, "/:owner/:repo/settings/access/invites/:id/revoke", async (c) => { | |
| 650 | + const l = await load(c); | |
| 651 | + return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { | |
| 652 | + if (!(await c.get("dir").revokeInvite(prm(c, "id"), { repoId: l.repo.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed"); | |
| 653 | + audit(c, l, "repo.invite.revoke", { id: prm(c, "id") }); | |
| 654 | + }, "Invitation revoked."); | |
| 655 | + }); | |
| 656 | + | |
| 648 | 657 | post(web, "/:owner/:repo/settings/access/people", async (c, f) => { |
| 649 | 658 | const l = await load(c); |
| 650 | 659 | const me = requireAccount(c); |
modifiedapps/diffinery/src/web/ui.tsx+37−0View file
| @@ -888,5 +888,42 @@ | ||
| 888 | 888 | /** "write" → "Write", for showing a stored value as a label. */ |
| 889 | 889 | export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); |
| 890 | 890 | |
| 891 | +export type PendingInvite = { id: string; email: string; role: string; createdAt: string; expiresAt: string; invitedBy: string | null }; | |
| 892 | + | |
| 893 | +/** Pending invitations with a Revoke button each; `revoke` gives the form action for an invitation id. */ | |
| 894 | +export const PendingInvites: FC<{ invites: PendingInvite[]; revoke: (id: string) => string; t: string; roles?: boolean }> = ({ invites, revoke, t, roles = true }) => | |
| 895 | + invites.length ? ( | |
| 896 | + <div class="box"> | |
| 897 | + <div class="head"> | |
| 898 | + <h3>{raw(ICONS.envelope)} Pending invitations</h3> | |
| 899 | + </div> | |
| 900 | + <table class="tbl"> | |
| 901 | + <tbody> | |
| 902 | + {invites.map((i) => ( | |
| 903 | + <tr> | |
| 904 | + <td> | |
| 905 | + <b>{i.email}</b> | |
| 906 | + <div class="muted small"> | |
| 907 | + Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""} | |
| 908 | + <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })} | |
| 909 | + </div> | |
| 910 | + </td> | |
| 911 | + {roles ? ( | |
| 912 | + <td> | |
| 913 | + <span class="badge">{cap(i.role)}</span> | |
| 914 | + </td> | |
| 915 | + ) : null} | |
| 916 | + <td class="right"> | |
| 917 | + <Form action={revoke(i.id)} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}> | |
| 918 | + <button class="btn sm danger">Revoke</button> | |
| 919 | + </Form> | |
| 920 | + </td> | |
| 921 | + </tr> | |
| 922 | + ))} | |
| 923 | + </tbody> | |
| 924 | + </table> | |
| 925 | + </div> | |
| 926 | + ) : null; | |
| 927 | + | |
| 891 | 928 | /** The browser's check for usernames, organization and agent names; the server's isValidLogin agrees (no dots). */ |
| 892 | 929 | export const LOGIN_PATTERN = "[A-Za-z0-9][A-Za-z0-9_\\-]{0,99}"; |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+41−0View file
| @@ -1808,6 +1808,47 @@ | ||
| 1808 | 1808 | assert.equal(second.status, 303); |
| 1809 | 1809 | assert.match(flashOf(second), /already has a pending invitation/); |
| 1810 | 1810 | |
| 1811 | + // ...and it can be revoked: on the access page, in the API and over MCP. Then the address can be invited again. | |
| 1812 | + const mcp = async (name, args) => { | |
| 1813 | + const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) }); | |
| 1814 | + const { result } = await r.json(); | |
| 1815 | + assert.equal(result.isError, false, result.content[0].text); | |
| 1816 | + return result.structuredContent; | |
| 1817 | + }; | |
| 1818 | + const repoInvites = async () => ok("GET", `/repos/${me.login}/people-rules/invitations`); | |
| 1819 | + const [pending] = await repoInvites(); | |
| 1820 | + assert.equal(pending.email, "newcomer@example.com"); | |
| 1821 | + assert.match(await (await fetch(`${base}${access}`)).text(), new RegExp(`action="${access}/invites/${pending.id}/revoke"`)); | |
| 1822 | + const revoked = await submit(access, `${access}/invites/${pending.id}/revoke`, {}); | |
| 1823 | + assert.match(flashOf(revoked), /Invitation revoked/); | |
| 1824 | + assert.deepEqual(await repoInvites(), []); | |
| 1825 | + assert.equal((await submit(access, `${access}/people`, { who: "newcomer@example.com", role: "read" })).status, 200); | |
| 1826 | + const [again] = await repoInvites(); | |
| 1827 | + assert.equal((await api("DELETE", `/repos/${me.login}/people-rules/invitations/${again.id}`)).status, 204); | |
| 1828 | + assert.equal((await api("DELETE", `/repos/${me.login}/people-rules/invitations/${again.id}`)).status, 404); | |
| 1829 | + await submit(access, `${access}/people`, { who: "newcomer@example.com", role: "read" }); | |
| 1830 | + const listed = (await mcp("list_invitations", { owner: me.login, repo: "people-rules" })).result; | |
| 1831 | + assert.deepEqual(listed.map((i) => i.email), ["newcomer@example.com"]); | |
| 1832 | + assert.equal((await mcp("revoke_invitation", { owner: me.login, repo: "people-rules", invitation_id: listed[0].id })).revoked, true); | |
| 1833 | + assert.deepEqual(await repoInvites(), []); | |
| 1834 | + | |
| 1835 | + // Organization and site invitations too; each revoke only reaches its own kind. | |
| 1836 | + await ok("POST", "/user/orgs", { login: "revokers" }); | |
| 1837 | + const orgInvite = await ok("POST", "/orgs/revokers/invitations", { email: "joiner@example.com" }); | |
| 1838 | + const siteInvite = await ok("POST", "/invitations", { email: "visitor@example.com" }); | |
| 1839 | + assert.deepEqual((await ok("GET", "/orgs/revokers/invitations")).map((i) => i.id), [orgInvite.id]); | |
| 1840 | + const site = await ok("GET", "/invitations"); | |
| 1841 | + assert.ok(site.some((i) => i.id === siteInvite.id) && !site.some((i) => i.id === orgInvite.id)); | |
| 1842 | + assert.equal((await api("DELETE", `/invitations/${orgInvite.id}`)).status, 404); | |
| 1843 | + assert.equal((await api("DELETE", `/orgs/revokers/invitations/${siteInvite.id}`)).status, 404); | |
| 1844 | + assert.equal((await api("DELETE", `/orgs/revokers/invitations/${orgInvite.id}`)).status, 204); | |
| 1845 | + await ok("POST", "/orgs/revokers/invitations", { email: "joiner@example.com" }); | |
| 1846 | + const [orgAgain] = (await mcp("list_invitations", { org: "revokers" })).result; | |
| 1847 | + assert.equal((await mcp("revoke_invitation", { org: "revokers", invitation_id: orgAgain.id })).revoked, true); | |
| 1848 | + assert.match(await (await fetch(`${base}/settings/admin`)).text(), new RegExp(`/settings/admin/invites/${siteInvite.id}/revoke`)); | |
| 1849 | + assert.match(flashOf(await submit("/settings/admin", `/settings/admin/invites/${siteInvite.id}/revoke`, {})), /Invitation revoked/); | |
| 1850 | + assert.ok(!(await ok("GET", "/invitations")).some((i) => i.id === siteInvite.id)); | |
| 1851 | + | |
| 1811 | 1852 | // A blank display name falls back to the username. |
| 1812 | 1853 | const saved = await submit("/settings", "/settings/profile", { login: me.login, name: " " }); |
| 1813 | 1854 | assert.match(flashOf(saved), /Profile saved\./); |
modifiedapps/diffinery/test/e2e/registry.test.mjs+17−0View file
| @@ -385,6 +385,23 @@ | ||
| 385 | 385 | } |
| 386 | 386 | }); |
| 387 | 387 | |
| 388 | +test("registry: an organization's package linked to a private repository follows that repository's access", async () => { | |
| 389 | + // With the base permission at none, members only reach the repositories they're given, and so their images. | |
| 390 | + await ok("PATCH", "/orgs/crew", { default_repository_permission: "none" }, CREW.olga); | |
| 391 | + await ok("POST", "/orgs/crew/repos", { name: "secret", private: true }, CREW.olga); | |
| 392 | + await pushImage("crew/secret", "v1", { tok: CREW.olga }); | |
| 393 | + assert.equal((await ok("GET", "/orgs/crew/packages/container/secret", undefined, CREW.olga)).repository.full_name, "crew/secret"); | |
| 394 | + assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 404); | |
| 395 | + assert.ok(!(await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret")); | |
| 396 | + assert.ok(!(await ok("GET", "/orgs/crew/packages", undefined, CREW.mira)).some((p) => p.name === "secret")); | |
| 397 | + // Unlinked packages are still the members' to pull, and a grant on the repository opens its image. | |
| 398 | + assert.equal((await reg("GET", "crew/nils-tool/manifests/v1", { tok: CREW.mira })).status, 200); | |
| 399 | + await ok("PUT", "/repos/crew/secret/collaborators/mira", { permission: "read" }, CREW.olga); | |
| 400 | + assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 200); | |
| 401 | + assert.ok((await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret")); | |
| 402 | + await ok("PATCH", "/orgs/crew", { default_repository_permission: "read" }, CREW.olga); | |
| 403 | +}); | |
| 404 | + | |
| 388 | 405 | function git(cwd, args) { |
| 389 | 406 | const a = Buffer.from(`x-token:${token}`).toString("base64"); |
| 390 | 407 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${a}`, "-c", "protocol.version=2", ...args], { |
modifiedapps/diffinery/test/e2e/security.test.mjs+70−1View file
| @@ -34,7 +34,7 @@ | ||
| 34 | 34 | |
| 35 | 35 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 36 | 36 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 37 | -const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo") }; | |
| 37 | +const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam") }; | |
| 38 | 38 | const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` }; |
| 39 | 39 | const OWNER = "olive"; |
| 40 | 40 | |
| @@ -123,6 +123,10 @@ | ||
| 123 | 123 | session("s1", "u-penny", S.penny), |
| 124 | 124 | session("s2", "u-dora", S.dora), |
| 125 | 125 | session("s3", "u-olive", S.olive), |
| 126 | + account("o-shop", "shop", "org", "active"), | |
| 127 | + account("u-sam", "sam", "user", "active"), | |
| 128 | + token("t9", "u-sam", T.sam, ["admin"]), | |
| 129 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, | |
| 126 | 130 | ].join("; "), |
| 127 | 131 | ); |
| 128 | 132 | dev = spawn( |
| @@ -584,3 +588,68 @@ | ||
| 584 | 588 | assert.deepEqual(await can("/repos/guild/milo-tools", T.milo), { admin: true, push: true, pull: true }); |
| 585 | 589 | await ok("PATCH", "/orgs/guild", { default_repository_permission: "read" }); |
| 586 | 590 | }); |
| 591 | + | |
| 592 | +/** A web form post as olive (signed in by session), with the CSRF pair taken from `page`. */ | |
| 593 | +async function oliveSubmits(page, action, fields) { | |
| 594 | + const cookie = `__Host-dfy_session=${S.olive}`; | |
| 595 | + const res = await fetch(`${base}${page}`, { headers: { cookie } }); | |
| 596 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 597 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 598 | + assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`); | |
| 599 | + const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); | |
| 600 | + return { status: r.status, flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; | |
| 601 | +} | |
| 602 | + | |
| 603 | +/** Call an MCP tool with a token. */ | |
| 604 | +async function mcpTool(as, name, args) { | |
| 605 | + const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${as}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) }); | |
| 606 | + const { result } = await r.json(); | |
| 607 | + return { isError: result.isError, text: result.content[0].text, data: result.structuredContent }; | |
| 608 | +} | |
| 609 | + | |
| 610 | +test("security: leaving an organization takes its repository access along", async () => { | |
| 611 | + // sam, a member of shop, creates a repository (and so administers it) and is given write on another, | |
| 612 | + // as is the agent he works with. | |
| 613 | + await ok("PATCH", "/orgs/shop", { default_repository_permission: "none" }); | |
| 614 | + await ok("POST", "/orgs/shop/repos", { name: "sam-lab", private: true }, T.sam); | |
| 615 | + await ok("POST", "/orgs/shop/repos", { name: "tools", private: true }); | |
| 616 | + const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, T.sam)).agent.login; | |
| 617 | + await status(204, "PUT", "/repos/shop/tools/collaborators/sam", { permission: "write" }); | |
| 618 | + await status(204, "PUT", `/repos/shop/tools/collaborators/${helper}`, { permission: "write" }); | |
| 619 | + assert.equal((await ok("GET", "/repos/shop/sam-lab", undefined, T.sam)).permissions.admin, true); | |
| 620 | + assert.equal((await ok("GET", "/repos/shop/tools", undefined, T.sam)).permissions.push, true); | |
| 621 | + | |
| 622 | + // Removing him from the organization removes those grants, his agent's included. | |
| 623 | + const removed = await oliveSubmits("/shop?tab=people", "/shop/-/org/members", { login: "sam", role: "remove" }); | |
| 624 | + assert.equal(removed.status, 303, removed.flash); | |
| 625 | + assert.doesNotMatch(removed.flash, /error/i); | |
| 626 | + await status(404, "GET", "/repos/shop/sam-lab", undefined, T.sam); | |
| 627 | + await status(404, "GET", "/repos/shop/tools", undefined, T.sam); | |
| 628 | + const logins = async (repo) => (await ok("GET", `/repos/shop/${repo}/collaborators`)).map((u) => u.login); | |
| 629 | + assert.ok(!(await logins("sam-lab")).includes("sam")); | |
| 630 | + assert.deepEqual((await logins("tools")).filter((l) => l === "sam" || l === helper), []); | |
| 631 | +}); | |
| 632 | + | |
| 633 | +test("security: organization settings are checked before anything is saved, and agents see the base permission", async () => { | |
| 634 | + // A non-string name is a 422, not a crash; a bad base permission doesn't let the rename through. | |
| 635 | + const bad = await status(422, "PATCH", "/orgs/guild", { name: 123 }); | |
| 636 | + assert.match(bad.message, /name must be a string/); | |
| 637 | + await status(422, "PATCH", "/orgs/guild", { name: "Halfway", default_repository_permission: "superuser" }); | |
| 638 | + assert.equal((await ok("GET", "/orgs/guild")).name, "guild"); | |
| 639 | + // The settings form does the same. | |
| 640 | + const form = await oliveSubmits("/guild?tab=settings", "/guild/-/org/settings", { login: "guild", name: "Halfway", base_role: "superuser" }); | |
| 641 | + assert.match(form.flash, /base permission must be/); | |
| 642 | + assert.equal((await ok("GET", "/orgs/guild")).name, "guild"); | |
| 643 | + const saved = await oliveSubmits("/guild?tab=settings", "/guild/-/org/settings", { login: "guild", name: "The Guild", base_role: "read" }); | |
| 644 | + assert.match(saved.flash, /Organization updated/); | |
| 645 | + assert.equal((await ok("GET", "/orgs/guild")).name, "The Guild"); | |
| 646 | + | |
| 647 | + // olive-bot works for an owner of guild: it sees the base permission, in the API and over MCP. | |
| 648 | + assert.equal((await ok("GET", "/orgs/guild", undefined, T.bot)).default_repository_permission, "read"); | |
| 649 | + const org = await mcpTool(T.bot, "get_organization", { org: "guild" }); | |
| 650 | + assert.equal(org.isError, false, org.text); | |
| 651 | + assert.equal(org.data.default_repository_permission, "read"); | |
| 652 | + // Outsiders don't. | |
| 653 | + assert.equal((await mcpTool(T.sam, "get_organization", { org: "guild" })).data.default_repository_permission, undefined); | |
| 654 | + assert.equal((await ok("GET", "/orgs/guild", undefined, T.sam)).default_repository_permission, undefined); | |
| 655 | +}); |
modifieddocs/container-registry.md+2−1View file
| @@ -105,7 +105,8 @@ | ||
| 105 | 105 | A package linked to a repository shares the repository's roles: readers pull, |
| 106 | 106 | writers push, admins delete versions and change settings. The owner (a user, |
| 107 | 107 | or an organization's owners and admins) always administers its packages. |
| 108 | -Organization members can create packages and pull the organization's packages; | |
| 108 | +Organization members can create packages and pull the organization's packages | |
| 109 | +that aren't linked to a repository (a linked one follows the member's role on it); | |
| 109 | 110 | a package that isn't linked to a repository is administered by the member who |
| 110 | 111 | created it, and other members can't push to it. |
| 111 | 112 |