Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,192 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The Packages page names every registry it has1//! Who may do what in a repository: repository roles, the capabilities
2//! each one carries, and how a person's permission is worked out.
3//!
4//! **One table.** [`CAPABILITIES`] says, for each [`Capability`], the
5//! least [`RepoRole`] that has it. Every service asks [`can`] (or
6//! [`permission`]) instead of checking membership itself, the site draws
7//! its Roles table from the same list, and
8//! `packages/contracts/src/access.ts` mirrors it (a test here reads that
9//! file and fails when the two differ).
10//!
11//! **Effective permission** is the highest of:
12//!
13//! - **ownership**: an owner of the repository's workspace has Admin on
14//! every repository in it;
15//! - **the base permission** of the workspace ([`BasePermission`]), which
Merge main (membership, two-factor, GitHub repo roles) into tokens16//! every member gets on every repository (Read for a workspace made from
17//! 2026-10-08, [`BasePermission::FOR_NEW_WORKSPACES`]; Write for one made
18//! before, until an owner changes it);
19//! - **a direct grant** ([`RepoGrant`]) to the person, on that repository.
20//! Whoever creates a repository is given Admin on it this way;
21//! - **security manager**: Read on every repository of a workspace where
22//! the person is one, with the security capabilities
23//! ([`SECURITY_MANAGER`]) on top;
The Packages page names every registry it has24//! - **public**: anyone, signed in or not, can read a public repository.
25//!
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar26//! - **a team's grant**: a role given to a team the person is in, or to
27//! one of that team's parents (see [`crate::teams`]). Identity resolves
28//! it into the same [`RepoGrant`]s, with [`RepoGrant::team`] set, so the
29//! rules here treat it as any other grant.
The Packages page names every registry it has30//!
31//! Identity attaches a person's grants ([`User::grants`]) and each
32//! membership's base permission ([`Membership::base_permission`]) when it
33//! resolves them from credentials, so asking costs nothing: no call, and
34//! the answer is as fresh as the request.
35//!
Token reach: workflow_files scope, fine-grained reach, workspace token cap36//! **Tokens.** A workspace's own token has Write on its workspace's
37//! repositories, as a member would, and Admin only when an owner gave it
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers38//! Repositories: admin ([`crate::scopes::TokenAccess::admin`]); what is
39//! for people only stays refused by the checks that say so. A token
40//! made for one workspace (or none) has no role outside it and its
41//! repository selection ([`crate::scopes::TokenReach`]): there it
Token reach: workflow_files scope, fine-grained reach, workspace token cap42//! reads public repositories, as anyone may, and nothing more. An
The Packages page names every registry it has43//! agent's token carries the memberships and grants of the person it acts
44//! for, cut down to its repository's workspace
45//! (`credentials::intersect`), so it never has more than that person on
46//! that repository, and its scope limits it further.
47
48use serde::{Deserialize, Serialize};
49
50use crate::repos::{Repo, RepoPath};
51use crate::{Membership, PrincipalKind, Role, User};
52
53/// What someone may do in one repository, from least to most.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum RepoRole {
57 /// Read and clone; open issues and pull requests, and comment.
58 Read,
59 /// Read, and manage issues and pull requests: label, assign, close.
60 Triage,
61 /// Triage, and push, merge, and put agents to work.
62 Write,
Merge main (membership, two-factor, GitHub repo roles) into tokens63 /// Write, and manage the repository's settings and topics.
The Packages page names every registry it has64 Maintain,
Merge main (membership, two-factor, GitHub repo roles) into tokens65 /// Everything: branch protection and rulesets, webhooks, secrets,
66 /// deployments, security settings, who has access, and the
The Packages page names every registry it has67 /// repository's name, visibility and archiving.
68 Admin,
69}
70
71impl RepoRole {
72 pub const ALL: [RepoRole; 5] = [
73 RepoRole::Read,
74 RepoRole::Triage,
75 RepoRole::Write,
76 RepoRole::Maintain,
77 RepoRole::Admin,
78 ];
79
80 pub fn as_str(self) -> &'static str {
81 match self {
82 RepoRole::Read => "read",
83 RepoRole::Triage => "triage",
84 RepoRole::Write => "write",
85 RepoRole::Maintain => "maintain",
86 RepoRole::Admin => "admin",
87 }
88 }
89
90 pub fn parse(text: &str) -> Option<RepoRole> {
91 RepoRole::ALL
92 .into_iter()
93 .find(|role| role.as_str() == text.trim().to_ascii_lowercase())
94 }
95
96 /// How people are shown it: "Read", "Triage"...
97 pub fn label(self) -> &'static str {
98 match self {
99 RepoRole::Read => "Read",
100 RepoRole::Triage => "Triage",
101 RepoRole::Write => "Write",
102 RepoRole::Maintain => "Maintain",
103 RepoRole::Admin => "Admin",
104 }
105 }
106}
107
108/// What every member of a workspace gets on each of its repositories.
Merge main (membership, two-factor, GitHub repo roles) into tokens109///
110/// Its `Default` is what a membership that does not say gets: one a service
111/// made up to act inside a workspace. Every workspace stores its own value,
112/// and a new one starts at [`BasePermission::FOR_NEW_WORKSPACES`].
The Packages page names every registry it has113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum BasePermission {
116 /// Nothing beyond what is public: members see the private
117 /// repositories they are given access to, and no others.
118 None,
119 Read,
120 /// What members could do before roles: push, merge, run agents.
121 #[default]
122 Write,
123 Admin,
124}
125
126impl BasePermission {
Merge main (membership, two-factor, GitHub repo roles) into tokens127 /// What a new workspace's members get: Read, as on GitHub. Workspaces
128 /// made before 2026-10-08 kept the Write they had.
129 pub const FOR_NEW_WORKSPACES: BasePermission = BasePermission::Read;
130
The Packages page names every registry it has131 pub const ALL: [BasePermission; 4] = [
132 BasePermission::None,
133 BasePermission::Read,
134 BasePermission::Write,
135 BasePermission::Admin,
136 ];
137
138 pub fn as_str(self) -> &'static str {
139 match self {
140 BasePermission::None => "none",
141 BasePermission::Read => "read",
142 BasePermission::Write => "write",
143 BasePermission::Admin => "admin",
144 }
145 }
146
147 pub fn parse(text: &str) -> Option<BasePermission> {
148 BasePermission::ALL
149 .into_iter()
150 .find(|base| base.as_str() == text.trim().to_ascii_lowercase())
151 }
152
153 /// The repository role it gives, if any.
154 pub fn role(self) -> Option<RepoRole> {
155 match self {
156 BasePermission::None => None,
157 BasePermission::Read => Some(RepoRole::Read),
158 BasePermission::Write => Some(RepoRole::Write),
159 BasePermission::Admin => Some(RepoRole::Admin),
160 }
161 }
162}
163
164/// Something that can be done in a repository.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Capability {
168 /// See the code, issues and pull requests; clone and fetch.
169 Read,
170 /// Open issues and pull requests, and comment on them.
171 Participate,
Merge main (membership, two-factor, GitHub repo roles) into tokens172 /// Apply labels and milestones; assign, close and reopen issues and
173 /// pull requests; ask for reviews.
The Packages page names every registry it has174 Triage,
175 /// Push to branches that are not protected, and edit files on the web.
176 Push,
177 /// Merge pull requests and manage the merge queue.
178 Merge,
Merge main (membership, two-factor, GitHub repo roles) into tokens179 /// Create, edit and delete labels and milestones.
180 ManageLabels,
181 /// See and dismiss security alerts: secret scanning, code scanning and
182 /// vulnerable dependencies.
183 SecurityAlerts,
The Packages page names every registry it has184 /// Assign agents, start runs, plans and workflows: anything that
185 /// spends compute.
186 Run,
187 /// Change the description, topics, website, and how pull requests and
188 /// agents work.
189 ManageSettings,
Merge main (membership, two-factor, GitHub repo roles) into tokens190 /// Change branch protection, rulesets and guardrails.
The Packages page names every registry it has191 ManageProtection,
Merge main (membership, two-factor, GitHub repo roles) into tokens192 /// Change security settings: scanning, push protection, custom
193 /// patterns and bypass reviews.
194 ManageSecurity,
The Packages page names every registry it has195 /// Manage webhooks, secrets and variables, deployments, domains and
196 /// integrations.
197 ManageIntegrations,
198 /// Add, change and remove who has access, and invitations.
199 ManageAccess,
Merge main (membership, two-factor, GitHub repo roles) into tokens200 /// Rename, archive, and change the default branch.
The Packages page names every registry it has201 Administer,
Merge main (membership, two-factor, GitHub repo roles) into tokens202 /// Make the repository public or private. Owners only when the
203 /// workspace's member privileges say so.
204 ChangeVisibility,
205 /// Transfer or delete the repository. Owners only unless the
206 /// workspace's member privileges let repository admins do it.
The Packages page names every registry it has207 Delete,
208}
209
210impl Capability {
211 pub fn as_str(self) -> &'static str {
212 match self {
213 Capability::Read => "read",
214 Capability::Participate => "participate",
215 Capability::Triage => "triage",
216 Capability::Push => "push",
217 Capability::Merge => "merge",
Merge main (membership, two-factor, GitHub repo roles) into tokens218 Capability::ManageLabels => "manage_labels",
219 Capability::SecurityAlerts => "security_alerts",
The Packages page names every registry it has220 Capability::Run => "run",
221 Capability::ManageSettings => "manage_settings",
222 Capability::ManageProtection => "manage_protection",
Merge main (membership, two-factor, GitHub repo roles) into tokens223 Capability::ManageSecurity => "manage_security",
The Packages page names every registry it has224 Capability::ManageIntegrations => "manage_integrations",
225 Capability::ManageAccess => "manage_access",
226 Capability::Administer => "administer",
Merge main (membership, two-factor, GitHub repo roles) into tokens227 Capability::ChangeVisibility => "change_visibility",
The Packages page names every registry it has228 Capability::Delete => "delete",
229 }
230 }
231}
232
233/// One row of the permission table.
234#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
235pub struct CapabilityRow {
236 pub capability: Capability,
237 /// The least role that has it.
238 pub role: RepoRole,
239 /// What it covers, as the Roles table shows it.
240 pub about: &'static str,
241}
242
243/// The permission table: the least role for each capability. The single
Merge main (membership, two-factor, GitHub repo roles) into tokens244/// source of truth; `packages/contracts/src/access.ts` mirrors it. It
245/// follows GitHub's table of repository roles; where g1t differs, the
246/// access guide says so.
247pub const CAPABILITIES: [CapabilityRow; 16] = [
The Packages page names every registry it has248 CapabilityRow { capability: Capability::Read, role: RepoRole::Read, about: "See code, issues and pull requests; clone and fetch" },
249 CapabilityRow { capability: Capability::Participate, role: RepoRole::Read, about: "Open issues and pull requests, and comment" },
Merge main (membership, two-factor, GitHub repo roles) into tokens250 CapabilityRow { capability: Capability::Triage, role: RepoRole::Triage, about: "Apply labels and milestones; assign, close and reopen issues and pull requests" },
The Packages page names every registry it has251 CapabilityRow { capability: Capability::Push, role: RepoRole::Write, about: "Push to branches that are not protected" },
252 CapabilityRow { capability: Capability::Merge, role: RepoRole::Write, about: "Merge pull requests and use the merge queue" },
Merge main (membership, two-factor, GitHub repo roles) into tokens253 CapabilityRow { capability: Capability::ManageLabels, role: RepoRole::Write, about: "Create, edit and delete labels and milestones" },
254 CapabilityRow { capability: Capability::SecurityAlerts, role: RepoRole::Write, about: "See and dismiss security alerts" },
The Packages page names every registry it has255 CapabilityRow { capability: Capability::Run, role: RepoRole::Write, about: "Assign agents and start runs, plans and workflows" },
256 CapabilityRow { capability: Capability::ManageSettings, role: RepoRole::Maintain, about: "Change the description, topics, and pull request and agent settings" },
Merge main (membership, two-factor, GitHub repo roles) into tokens257 CapabilityRow { capability: Capability::ManageProtection, role: RepoRole::Admin, about: "Change branch protection, rulesets and guardrails" },
258 CapabilityRow { capability: Capability::ManageSecurity, role: RepoRole::Admin, about: "Change security settings, custom patterns and bypass reviews" },
The Packages page names every registry it has259 CapabilityRow { capability: Capability::ManageIntegrations, role: RepoRole::Admin, about: "Manage webhooks, secrets, variables, deployments and domains" },
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca260 CapabilityRow { capability: Capability::ManageAccess, role: RepoRole::Admin, about: "Manage who has access, invitations and deploy keys" },
Merge main (membership, two-factor, GitHub repo roles) into tokens261 CapabilityRow { capability: Capability::Administer, role: RepoRole::Admin, about: "Rename, archive and change the default branch" },
262 CapabilityRow { capability: Capability::ChangeVisibility, role: RepoRole::Admin, about: "Change visibility (owners only, unless member privileges allow admins)" },
263 CapabilityRow { capability: Capability::Delete, role: RepoRole::Admin, about: "Transfer or delete the repository (owners only, unless member privileges allow admins)" },
The Packages page names every registry it has264];
265
266/// Capabilities that also need an owner of the repository's workspace,
Merge main (membership, two-factor, GitHub repo roles) into tokens267/// whatever a person's role on the repository, unless the workspace's
268/// member privileges ([`crate::MemberPrivileges`]) let its members with
269/// the Admin role do them: see [`owner_only`].
270pub const OWNER_ONLY: [Capability; 2] = [Capability::ChangeVisibility, Capability::Delete];
271
272/// What a security manager may do on every repository of their workspace,
273/// whatever their role on it: read it, and see and manage its security.
274pub const SECURITY_MANAGER: [Capability; 4] =
275 [Capability::Read, Capability::Participate, Capability::SecurityAlerts, Capability::ManageSecurity];
276
277/// Whether `capability` needs an owner of a workspace whose member
278/// privileges are `privileges`, for a member with the Admin role.
279pub fn owner_only(capability: Capability, privileges: &crate::MemberPrivileges) -> bool {
280 match capability {
281 Capability::ChangeVisibility => !privileges.members_can_change_repo_visibility,
282 Capability::Delete => !privileges.members_can_delete_repositories,
283 _ => false,
284 }
285}
The Packages page names every registry it has286
287/// The least role that has `capability`.
288pub fn least_role(capability: Capability) -> RepoRole {
289 CAPABILITIES
290 .iter()
291 .find(|row| row.capability == capability)
292 .map_or(RepoRole::Admin, |row| row.role)
293}
294
295/// Whether `role` has `capability`, going by the table alone.
296pub fn allows(role: RepoRole, capability: Capability) -> bool {
297 role >= least_role(capability)
298}
299
300/// A person's role on one repository, given to them directly. Attached by
301/// identity to every user it resolves ([`User::grants`]).
302#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
303pub struct RepoGrant {
304 pub repo_id: String,
305 /// The repository's workspace, by slug, as it is now.
306 pub workspace: String,
307 pub role: RepoRole,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar308 /// The team it comes through, by slug, when it is a team's grant.
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub team: Option<String>,
The Packages page names every registry it has311}
312
313/// What [`permission`] needs to know about a repository.
314#[derive(Clone, Copy, Debug)]
315pub struct RepoRef<'a> {
316 pub id: &'a str,
317 /// Its workspace's slug.
318 pub namespace: &'a str,
319 pub private: bool,
320}
321
322impl<'a> From<&'a Repo> for RepoRef<'a> {
323 fn from(repo: &'a Repo) -> Self {
324 RepoRef {
325 id: &repo.id,
326 namespace: &repo.namespace,
327 private: repo.is_private,
328 }
329 }
330}
331
332/// What a membership gives on each of the workspace's repositories.
333fn membership_role(user: &User, membership: &Membership) -> Option<RepoRole> {
Token reach: workflow_files scope, fine-grained reach, workspace token cap334 // A workspace's own token has Write on its repositories, as a member
335 // would, unless an owner gave it Admin when making it. A workflow job's
336 // token and a deploy key resolve the same way, capped further by their
337 // scopes. g1t acting in the workspace, and a service acting as the
338 // workspace (no token), do what an owner can.
339 if user.kind == PrincipalKind::Workspace
340 && let Some(token) = user.token.as_deref()
341 {
342 return Some(if token.admin { RepoRole::Admin } else { RepoRole::Write });
343 }
The Packages page names every registry it has344 if matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) {
345 return Some(RepoRole::Admin);
346 }
347 match membership.role {
348 Role::Owner => Some(RepoRole::Admin),
Merge main (membership, two-factor, GitHub repo roles) into tokens349 Role::Member => {
350 let base = membership.base_permission.unwrap_or_default().role();
351 // A security manager reads every repository.
352 if membership.has(crate::OrgRole::SecurityManager) {
353 base.max(Some(RepoRole::Read))
354 } else {
355 base
356 }
357 }
The Packages page names every registry it has358 }
359}
360
361/// `user`'s role on the repository, not counting that it may be public.
362pub fn granted(user: &User, repo: RepoRef<'_>) -> Option<RepoRole> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers363 // A token outside the workspace it is made for, or its repository
Token reach: workflow_files scope, fine-grained reach, workspace token cap364 // selection, has no role there: a public repository still reads.
365 if user.token.as_deref().is_some_and(|token| !token.covers_repo(repo.id, repo.namespace)) {
366 return None;
367 }
The Packages page names every registry it has368 let namespace = repo.namespace.to_lowercase();
369 let from_membership = user
370 .workspaces
371 .iter()
372 .find(|membership| membership.slug.eq_ignore_ascii_case(&namespace))
373 .and_then(|membership| membership_role(user, membership));
374 let direct = user
375 .grants
376 .iter()
377 .filter(|grant| grant.repo_id == repo.id)
378 .map(|grant| grant.role)
379 .max();
380 from_membership.max(direct)
381}
382
383/// The viewer's effective role on a repository: `None` means they may not
384/// see it at all (a private repository then looks missing).
385pub fn permission<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>) -> Option<RepoRole> {
386 let repo = repo.into();
387 let role = viewer.and_then(|user| granted(user, repo));
388 if repo.private {
389 role
390 } else {
391 role.max(Some(RepoRole::Read))
392 }
393}
394
395/// Whether the viewer may do `capability` in the repository. Owner-only
Merge main (membership, two-factor, GitHub repo roles) into tokens396/// capabilities ([`OWNER_ONLY`]) also need the viewer to own its workspace,
397/// or to be a member of it whose member privileges allow it. A security
398/// manager of its workspace may do what [`SECURITY_MANAGER`] lists.
The Packages page names every registry it has399pub fn can<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>, capability: Capability) -> bool {
400 let repo = repo.into();
401 let Some(role) = permission(viewer, repo) else {
402 return false;
403 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers404 // Where a narrowed token does not reach, it only reads.
Token reach: workflow_files scope, fine-grained reach, workspace token cap405 if capability != Capability::Read
406 && viewer
407 .and_then(|user| user.token.as_deref())
408 .is_some_and(|token| !token.covers_repo(repo.id, repo.namespace))
409 {
410 return false;
411 }
Merge main (membership, two-factor, GitHub repo roles) into tokens412 let namespace = repo.namespace.to_lowercase();
The Packages page names every registry it has413 if !allows(role, capability) {
Merge main (membership, two-factor, GitHub repo roles) into tokens414 return SECURITY_MANAGER.contains(&capability)
415 && viewer.is_some_and(|user| is_security_manager(user, &namespace));
The Packages page names every registry it has416 }
417 if OWNER_ONLY.contains(&capability) {
Merge main (membership, two-factor, GitHub repo roles) into tokens418 return viewer.is_some_and(|user| match user.membership(&namespace) {
419 Some(membership) if membership.role == Role::Owner => true,
420 // A member with the Admin role, when the privileges allow it;
421 // never an outside collaborator.
422 Some(membership) => !owner_only(capability, &membership.privileges.unwrap_or_default()),
423 None => false,
424 });
The Packages page names every registry it has425 }
426 true
427}
428
Merge main (membership, two-factor, GitHub repo roles) into tokens429/// Whether `user` is a security manager of the workspace `namespace`
430/// (a person, not a token acting for one).
431pub fn is_security_manager(user: &User, namespace: &str) -> bool {
432 user.kind == PrincipalKind::User
433 && user
434 .membership(namespace)
435 .is_some_and(|membership| membership.has(crate::OrgRole::SecurityManager))
436}
437
The Packages page names every registry it has438/// What a refusal answers: a repository the viewer cannot read is not
439/// found (so private ones cannot be told from missing ones); one they can
440/// read but not act in is forbidden.
441#[derive(Clone, Copy, Debug, PartialEq, Eq)]
442pub enum Denied {
443 NotFound,
444 Forbidden,
445}
446
447/// `Ok` when the viewer may do `capability`; otherwise whether to answer
448/// not found or forbidden.
449pub fn check<'a>(
450 viewer: Option<&User>,
451 repo: impl Into<RepoRef<'a>>,
452 capability: Capability,
453) -> Result<(), Denied> {
454 let repo = repo.into();
455 if permission(viewer, repo).is_none() {
456 return Err(Denied::NotFound);
457 }
458 if can(viewer, repo, capability) {
459 Ok(())
460 } else {
461 Err(Denied::Forbidden)
462 }
463}
464
465/// The sentence a refusal of `capability` gives.
466pub fn needs(capability: Capability, repo: &str) -> String {
467 let role = least_role(capability);
468 if OWNER_ONLY.contains(&capability) {
Merge main (membership, two-factor, GitHub repo roles) into tokens469 return format!(
470 "Only an owner of the workspace can do that to {repo}, unless its member privileges let members with the {} role do it.",
471 role.label()
472 );
The Packages page names every registry it has473 }
474 format!(
475 "You need the {} role or higher on {repo} to do that.",
476 role.label()
477 )
478}
479
480/// Whether the user has any way into the workspace `namespace`: a member,
481/// or someone with a role on one of its repositories.
482pub fn has_access_in(user: &User, namespace: &str) -> bool {
483 let namespace = namespace.to_lowercase();
484 user.is_member(&namespace) || user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
485}
486
487/// Whether the user is an outside collaborator of `namespace`: they have
488/// roles on some of its repositories without belonging to it.
489pub fn is_outside_collaborator(user: &User, namespace: &str) -> bool {
490 let namespace = namespace.to_lowercase();
491 !user.is_member(&namespace) && user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
492}
493
494// --- Who has access ---------------------------------------------------------
495
496/// How a person has their role on a repository.
497#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(rename_all = "snake_case")]
499pub enum AccessSource {
500 /// An owner of the workspace: Admin on everything in it.
501 Owner,
502 /// A member, through the workspace's base permission.
503 Base,
504 /// Given a role on this repository directly.
505 Direct,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar506 /// In a team given a role on this repository, or in a child of one.
507 Team,
The Packages page names every registry it has508}
509
510/// One person with access to a repository.
511#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
512pub struct Collaborator {
513 pub username: String,
514 /// Their display name, if they set one.
515 pub name: Option<String>,
516 pub avatar: Option<String>,
517 /// Their effective role: the highest of what they have.
518 pub role: RepoRole,
519 /// Where the effective role comes from.
520 pub source: AccessSource,
521 /// Their direct grant on this repository, if any (even when the base
522 /// permission or ownership gives more).
523 pub direct: Option<RepoRole>,
524 /// `owner`, `member`, or null for an outside collaborator.
525 pub workspace_role: Option<Role>,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar526 /// The highest role a team gives them here, and that team's slug.
527 #[serde(default)]
528 pub team_role: Option<RepoRole>,
529 #[serde(default)]
530 pub team: Option<String>,
The Packages page names every registry it has531}
532
533/// Where an invitation to a repository stands.
534#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536pub enum RepoInvitationStatus {
537 Pending,
538 Accepted,
539 Declined,
540 Revoked,
541 Expired,
542}
543
544/// An invitation to collaborate on one repository.
545#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
546pub struct RepoInvitation {
547 pub id: String,
548 /// `workspace/name`, as it is now.
549 pub repo: String,
550 pub repo_id: String,
551 /// Who is invited, when they have an account.
552 pub invitee: Option<String>,
553 /// The address it was sent to, when they had no account yet. Shown
554 /// only to whoever may manage the repository's access.
555 pub email: Option<String>,
556 pub role: RepoRole,
557 /// Who sent it, by username.
558 pub invited_by: Option<String>,
559 /// The avatar of who sent it: the SHA-256 of its bytes, served at
560 /// `/avatars/<avatar>`. None means the generated letter avatar.
561 #[serde(default)]
562 pub inviter_avatar: Option<String>,
563 pub status: RepoInvitationStatus,
564 /// RFC 3339.
565 pub created_at: String,
566 /// RFC 3339.
567 pub expires_at: String,
568}
569
570/// Who has access to a repository, as its Access settings show it.
571#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
572pub struct RepoAccess {
573 pub repo: String,
574 pub base_permission: BasePermission,
575 /// Everyone with access other than through the repository being
576 /// public: owners, members with a base role, and direct grants.
577 pub people: Vec<Collaborator>,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar578 /// The workspace's teams given a role on it.
579 #[serde(default)]
580 pub teams: Vec<crate::teams::RepoTeam>,
The Packages page names every registry it has581 /// Pending invitations. Empty unless the viewer may manage access.
582 pub invitations: Vec<RepoInvitation>,
583 /// The viewer's own role, and whether they may change who has access.
584 pub viewer_role: Option<RepoRole>,
585 pub can_manage: bool,
586}
587
588/// What adding someone did.
589#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(tag = "result", rename_all = "snake_case")]
591pub enum Added {
592 /// A member of the workspace: given the role at once.
593 Granted { collaborator: Collaborator },
594 /// Anyone else: sent an invitation to accept.
595 Invited { invitation: RepoInvitation },
596}
597
598/// A person's permission on a repository, as
599/// `GET /repos/{owner}/{name}/collaborators/{username}/permission` answers.
600#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
601pub struct PermissionInfo {
602 pub username: String,
603 /// Their role, or null when they have none (on a public repository
604 /// everyone reads it, which this does not count).
605 pub role: Option<RepoRole>,
606 pub source: Option<AccessSource>,
607 /// What the role lets them do, from the permission table.
608 pub capabilities: Vec<Capability>,
609}
610
611/// The capabilities `role` has, in the table's order.
612pub fn capabilities_of(role: Option<RepoRole>) -> Vec<Capability> {
613 CAPABILITIES
614 .iter()
615 .filter(|row| role.is_some_and(|role| role >= row.role))
616 .map(|row| row.capability)
617 .collect()
618}
619
620/// An outside collaborator of a workspace, and what they can reach.
621#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
622pub struct OutsideCollaborator {
623 pub username: String,
624 pub name: Option<String>,
625 pub avatar: Option<String>,
626 pub repos: Vec<CollaboratorRepo>,
627}
628
629#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
630pub struct CollaboratorRepo {
631 /// `workspace/name`.
632 pub repo: String,
633 pub role: RepoRole,
634}
635
636// --- Identity methods ---------------------------------------------------------
637//
638// Served by identity at `POST /rpc/<method>`. Each takes the repository's
639// path and the person asking; identity asks the repos service for the
640// repository as that person sees it, so a repository they cannot read is
641// not found, and one they can read without managing its access is
642// forbidden. Agents' tokens can never change access.
643
644/// `repo_access`: who has access to a repository. Needs Write (as the
645/// list of collaborators does); invitations need Admin.
646/// Returns `Outcome<RepoAccess>`.
647#[derive(Debug, Serialize, Deserialize)]
648pub struct RepoAccessArgs {
649 pub viewer: crate::Viewer,
650 pub path: RepoPath,
651}
652
653/// `add_collaborator`: gives `invitee` (a username or an email address)
654/// `role` on a repository. A member of its workspace gets it at once; a
655/// person with an account is sent an invitation to accept; an address
656/// without one is sent an invite code that makes their account and
657/// accepts. Admin only. Returns `Outcome<Added>`.
658#[derive(Debug, Serialize, Deserialize)]
659pub struct AddCollaboratorArgs {
660 pub actor: User,
661 pub path: RepoPath,
662 pub invitee: String,
663 pub role: RepoRole,
664 #[serde(default)]
665 pub surface: Option<crate::audit::Surface>,
666}
667
668/// `set_collaborator_role`: changes a direct grant, or a pending
669/// invitation's role. Admin only. Returns `Outcome<Collaborator>`.
670#[derive(Debug, Serialize, Deserialize)]
671pub struct SetCollaboratorRoleArgs {
672 pub actor: User,
673 pub path: RepoPath,
674 pub username: String,
675 pub role: RepoRole,
676 #[serde(default)]
677 pub surface: Option<crate::audit::Surface>,
678}
679
680/// `remove_collaborator`: takes away a direct grant. Admin only; anyone
681/// may remove themselves. Owners and the base permission are not changed
682/// here. Returns `Outcome<bool>`.
683#[derive(Debug, Serialize, Deserialize)]
684pub struct RemoveCollaboratorArgs {
685 pub actor: User,
686 pub path: RepoPath,
687 pub username: String,
688 #[serde(default)]
689 pub surface: Option<crate::audit::Surface>,
690}
691
692/// `collaborator_permission`: `username`'s role on a repository. Needs
693/// Write, or to be asking about yourself. Returns `Outcome<PermissionInfo>`.
694#[derive(Debug, Serialize, Deserialize)]
695pub struct CollaboratorPermissionArgs {
696 pub viewer: crate::Viewer,
697 pub path: RepoPath,
698 pub username: String,
699}
700
701/// `my_repo_invitations`: the invitations waiting for `user` to answer.
702/// Returns `Vec<RepoInvitation>`.
703#[derive(Debug, Serialize, Deserialize)]
704pub struct MyRepoInvitationsArgs {
705 pub user: User,
706}
707
708/// `respond_repo_invitation`: accept or decline an invitation sent to you.
709/// Accepting is checked against the workspace's policy. Returns
710/// `Outcome<RepoInvitation>`.
711#[derive(Debug, Serialize, Deserialize)]
712pub struct RespondRepoInvitationArgs {
713 pub user: User,
714 pub id: String,
715 pub accept: bool,
716}
717
718/// `revoke_repo_invitation`: withdraw a pending invitation. Admin only.
719/// Returns `Outcome<RepoInvitation>`.
720#[derive(Debug, Serialize, Deserialize)]
721pub struct RevokeRepoInvitationArgs {
722 pub actor: User,
723 pub path: RepoPath,
724 pub id: String,
725 #[serde(default)]
726 pub surface: Option<crate::audit::Surface>,
727}
728
729/// `set_base_permission`: what every member gets on every repository.
730/// Owners only, as a person. Returns `Outcome<BasePermission>`.
731#[derive(Debug, Serialize, Deserialize)]
732pub struct SetBasePermissionArgs {
733 pub actor: User,
734 pub slug: String,
735 pub base_permission: BasePermission,
736 #[serde(default)]
737 pub surface: Option<crate::audit::Surface>,
738}
739
740/// `outside_collaborators`: the people with roles on a workspace's
741/// repositories who are not its members. Owners only. Returns
742/// `Outcome<Vec<OutsideCollaborator>>`.
743#[derive(Debug, Serialize, Deserialize)]
744pub struct OutsideCollaboratorsArgs {
745 pub viewer: crate::Viewer,
746 pub slug: String,
747}
748
749/// `forget_repo_access`: a repository was purged; its grants and
750/// invitations go with it. For the repos service. Returns `bool`.
751#[derive(Debug, Serialize, Deserialize)]
752pub struct ForgetRepoAccessArgs {
753 pub repo_id: String,
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 fn user(memberships: &[(&str, Role, Option<BasePermission>)], grants: &[(&str, &str, RepoRole)]) -> User {
761 User {
762 id: "usr_1".into(),
763 username: "ana".into(),
764 verified: true,
765 workspaces: memberships
766 .iter()
767 .map(|(slug, role, base)| Membership {
768 slug: (*slug).into(),
769 role: *role,
770 name: None,
771 avatar: None,
772 base_permission: *base,
Merge branch 'worktree-agent-ad7c6d88d93adc817'773 team_creation: None,
Merge main (membership, two-factor, GitHub repo roles) into tokens774 org_roles: Vec::new(),
775 privileges: None,
The Packages page names every registry it has776 })
777 .collect(),
778 grants: grants
779 .iter()
780 .map(|(id, workspace, role)| RepoGrant {
781 repo_id: (*id).into(),
782 workspace: (*workspace).into(),
783 role: *role,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar784 team: None,
The Packages page names every registry it has785 })
786 .collect(),
787 ..User::default()
788 }
789 }
790
791 fn repo(id: &'static str, namespace: &'static str, private: bool) -> RepoRef<'static> {
792 RepoRef { id, namespace, private }
793 }
794
795 /// Every role against every capability: the whole table, spelled out.
796 #[test]
797 fn every_role_has_exactly_the_capabilities_of_the_table() {
798 use Capability::*;
799 let expected: [(RepoRole, &[Capability]); 5] = [
800 (RepoRole::Read, &[Read, Participate]),
801 (RepoRole::Triage, &[Read, Participate, Triage]),
Merge main (membership, two-factor, GitHub repo roles) into tokens802 (RepoRole::Write, &[Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run]),
The Packages page names every registry it has803 (
804 RepoRole::Maintain,
Merge main (membership, two-factor, GitHub repo roles) into tokens805 &[Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run, ManageSettings],
The Packages page names every registry it has806 ),
807 (
808 RepoRole::Admin,
809 &[
Merge main (membership, two-factor, GitHub repo roles) into tokens810 Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run, ManageSettings,
811 ManageProtection, ManageSecurity, ManageIntegrations, ManageAccess, Administer, ChangeVisibility,
812 Delete,
The Packages page names every registry it has813 ],
814 ),
815 ];
816 for (role, has) in expected {
817 for row in CAPABILITIES {
818 assert_eq!(
819 allows(role, row.capability),
820 has.contains(&row.capability),
821 "{} and {}",
822 role.as_str(),
823 row.capability.as_str()
824 );
825 }
826 assert_eq!(capabilities_of(Some(role)), has.to_vec());
827 }
828 assert!(capabilities_of(None).is_empty());
829 }
830
831 #[test]
832 fn read_cannot_spend_compute() {
833 assert!(!allows(RepoRole::Read, Capability::Run));
834 assert!(!allows(RepoRole::Triage, Capability::Run));
835 assert!(allows(RepoRole::Write, Capability::Run));
836 }
837
838 #[test]
839 fn owners_have_admin_on_everything_in_their_workspace() {
840 let owner = user(&[("acme", Role::Owner, Some(BasePermission::None))], &[]);
841 assert_eq!(permission(Some(&owner), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
842 assert!(can(Some(&owner), repo("rep_1", "acme", true), Capability::Delete));
843 }
844
845 #[test]
846 fn members_get_the_base_permission_and_write_when_unset() {
847 let unset = user(&[("acme", Role::Member, None)], &[]);
848 assert_eq!(permission(Some(&unset), repo("rep_1", "acme", true)), Some(RepoRole::Write));
849 let read = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
850 assert_eq!(permission(Some(&read), repo("rep_1", "acme", true)), Some(RepoRole::Read));
851 let none = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
852 assert_eq!(permission(Some(&none), repo("rep_1", "acme", true)), None);
853 assert_eq!(permission(Some(&none), repo("rep_1", "acme", false)), Some(RepoRole::Read));
854 }
855
856 /// The default keeps what members could do before roles: read, push,
857 /// merge, run agents.
858 #[test]
859 fn the_default_base_permission_keeps_members_working() {
860 assert_eq!(BasePermission::default(), BasePermission::Write);
861 let member = user(&[("acme", Role::Member, None)], &[]);
862 for capability in [Capability::Read, Capability::Participate, Capability::Push, Capability::Merge, Capability::Run] {
863 assert!(can(Some(&member), repo("rep_1", "acme", true), capability));
864 }
865 // Transfer and delete were owners' only, and still are.
866 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::Delete));
867 }
868
869 #[test]
870 fn effective_permission_is_the_highest_source() {
871 let member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Maintain)]);
872 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
873 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
874 // A grant lower than the base changes nothing.
875 let member = user(&[("acme", Role::Member, Some(BasePermission::Admin))], &[("rep_1", "acme", RepoRole::Read)]);
876 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
877 }
878
879 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar880 fn a_teams_grant_counts_like_any_other_and_the_highest_wins() {
881 let mut member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
882 member.grants.push(RepoGrant {
883 repo_id: "rep_1".into(),
884 workspace: "acme".into(),
885 role: RepoRole::Maintain,
886 team: Some("backend".into()),
887 });
888 member.grants.push(RepoGrant {
889 repo_id: "rep_1".into(),
890 workspace: "acme".into(),
891 role: RepoRole::Triage,
892 team: None,
893 });
894 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
Merge main (membership, two-factor, GitHub repo roles) into tokens895 assert!(can(Some(&member), repo("rep_1", "acme", true), Capability::ManageSettings));
896 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::ManageProtection));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar897 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::ManageAccess));
898 // Elsewhere, only the base.
899 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
900 // Serialized without `team` when it is a person's own.
901 let own = serde_json::to_value(&member.grants[1]).unwrap();
902 assert!(own.get("team").is_none());
903 assert_eq!(serde_json::to_value(&member.grants[0]).unwrap()["team"], "backend");
904 }
905
906 #[test]
The Packages page names every registry it has907 fn outside_collaborators_reach_only_their_repositories() {
908 let outsider = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
909 assert_eq!(permission(Some(&outsider), repo("rep_1", "acme", true)), Some(RepoRole::Triage));
910 assert_eq!(permission(Some(&outsider), repo("rep_2", "acme", true)), None);
911 assert_eq!(check(Some(&outsider), repo("rep_2", "acme", true), Capability::Read), Err(Denied::NotFound));
912 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Push), Err(Denied::Forbidden));
913 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Triage), Ok(()));
914 assert!(is_outside_collaborator(&outsider, "acme"));
915 assert!(has_access_in(&outsider, "acme"));
916 assert!(!has_access_in(&outsider, "globex"));
917 }
918
919 #[test]
920 fn a_direct_admin_cannot_transfer_or_delete() {
921 let admin = user(&[], &[("rep_1", "acme", RepoRole::Admin)]);
922 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::Administer));
923 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::ManageAccess));
924 assert!(!can(Some(&admin), repo("rep_1", "acme", true), Capability::Delete));
925 }
926
Merge main (membership, two-factor, GitHub repo roles) into tokens927 /// GitHub's table, row by row where g1t has the action: the least role
928 /// each one takes there.
929 #[test]
930 fn the_table_matches_githubs_repository_roles() {
931 let github: [(Capability, RepoRole); 16] = [
932 // "Pull from the repository", "View ..."
933 (Capability::Read, RepoRole::Read),
934 // "Open issues", "Comment on issues and pull requests"
935 (Capability::Participate, RepoRole::Read),
936 // "Apply/dismiss labels", "Apply milestones", "Close, reopen,
937 // and assign all issues and pull requests"
938 (Capability::Triage, RepoRole::Triage),
939 // "Push to (write) the person or team's assigned repositories"
940 (Capability::Push, RepoRole::Write),
941 // "Merge pull requests"
942 (Capability::Merge, RepoRole::Write),
943 // "Create, edit, delete labels", "Create, edit, delete milestones"
944 (Capability::ManageLabels, RepoRole::Write),
945 // "Receive and dismiss Dependabot alerts", "List, dismiss, and
946 // delete code scanning alerts", "View and dismiss secret
947 // scanning alerts"
948 (Capability::SecurityAlerts, RepoRole::Write),
949 // "Create, edit, run, re-run, and cancel GitHub Actions workflows"
950 (Capability::Run, RepoRole::Write),
951 // "Edit a repository's description", "Manage topics", "Manage
952 // pull request merges"
953 (Capability::ManageSettings, RepoRole::Maintain),
954 // "Manage branch protection rules and repository rulesets"
955 (Capability::ManageProtection, RepoRole::Admin),
956 // "Manage security and analysis features"
957 (Capability::ManageSecurity, RepoRole::Admin),
958 // "Manage webhooks and deploy keys"
959 (Capability::ManageIntegrations, RepoRole::Admin),
960 // "Manage individual and team access to the repository"
961 (Capability::ManageAccess, RepoRole::Admin),
962 // "Rename a repository", "Archive repositories", "Change the
963 // default branch"
964 (Capability::Administer, RepoRole::Admin),
965 // "Change a repository's visibility"
966 (Capability::ChangeVisibility, RepoRole::Admin),
967 // "Delete or transfer repositories"
968 (Capability::Delete, RepoRole::Admin),
969 ];
970 for (capability, role) in github {
971 assert_eq!(least_role(capability), role, "{}", capability.as_str());
972 }
973 assert_eq!(github.len(), CAPABILITIES.len());
974 }
975
976 fn with_privileges(mut person: User, privileges: crate::MemberPrivileges) -> User {
977 for membership in &mut person.workspaces {
978 membership.privileges = Some(privileges);
979 }
980 person
981 }
982
983 #[test]
984 fn member_privileges_decide_whether_admins_change_visibility_delete_and_transfer() {
985 let admin = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Admin)]);
986 let rep = repo("rep_1", "acme", true);
987 // The defaults: admins change visibility; only owners delete.
988 assert!(can(Some(&admin), rep, Capability::ChangeVisibility));
989 assert!(!can(Some(&admin), rep, Capability::Delete));
990 let open = with_privileges(
991 admin.clone(),
992 crate::MemberPrivileges { members_can_delete_repositories: true, ..crate::MemberPrivileges::default() },
993 );
994 assert!(can(Some(&open), rep, Capability::Delete));
995 let closed = with_privileges(
996 admin.clone(),
997 crate::MemberPrivileges { members_can_change_repo_visibility: false, ..crate::MemberPrivileges::default() },
998 );
999 assert!(!can(Some(&closed), rep, Capability::ChangeVisibility));
1000 // A writer never can, whatever the privileges.
1001 let writer = with_privileges(
1002 user(&[("acme", Role::Member, Some(BasePermission::Write))], &[]),
1003 crate::MemberPrivileges { members_can_delete_repositories: true, ..crate::MemberPrivileges::default() },
1004 );
1005 assert!(!can(Some(&writer), rep, Capability::Delete));
1006 // Owners always can.
1007 let owner = with_privileges(
1008 user(&[("acme", Role::Owner, None)], &[]),
1009 crate::MemberPrivileges { members_can_change_repo_visibility: false, ..crate::MemberPrivileges::default() },
1010 );
1011 assert!(can(Some(&owner), rep, Capability::ChangeVisibility));
1012 assert!(can(Some(&owner), rep, Capability::Delete));
1013 }
1014
1015 #[test]
1016 fn a_security_manager_reads_everything_and_manages_its_security_only() {
1017 let mut manager = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
1018 manager.workspaces[0].org_roles.push(crate::OrgRole::SecurityManager);
1019 let rep = repo("rep_1", "acme", true);
1020 assert_eq!(permission(Some(&manager), rep), Some(RepoRole::Read));
1021 for capability in SECURITY_MANAGER {
1022 assert!(can(Some(&manager), rep, capability), "{}", capability.as_str());
1023 }
1024 for capability in [Capability::Triage, Capability::Push, Capability::ManageSettings, Capability::ManageProtection] {
1025 assert!(!can(Some(&manager), rep, capability), "{}", capability.as_str());
1026 }
1027 // Elsewhere, nothing.
1028 assert_eq!(permission(Some(&manager), repo("rep_2", "globex", true)), None);
1029 // A billing manager gets no repository access from the role.
1030 let mut billing = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
1031 billing.workspaces[0].org_roles.push(crate::OrgRole::BillingManager);
1032 assert_eq!(permission(Some(&billing), rep), None);
1033 assert!(billing.manages_billing("acme"));
1034 assert!(!billing.manages_security("acme"));
1035 }
1036
1037 #[test]
1038 fn a_maintainer_no_longer_changes_branch_protection() {
1039 let maintainer = user(&[], &[("rep_1", "acme", RepoRole::Maintain)]);
1040 let rep = repo("rep_1", "acme", true);
1041 assert!(can(Some(&maintainer), rep, Capability::ManageSettings));
1042 assert!(!can(Some(&maintainer), rep, Capability::ManageProtection));
1043 let triager = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
1044 assert!(can(Some(&triager), rep, Capability::Triage));
1045 assert!(!can(Some(&triager), rep, Capability::ManageLabels));
1046 let writer = user(&[], &[("rep_1", "acme", RepoRole::Write)]);
1047 assert!(can(Some(&writer), rep, Capability::ManageLabels));
1048 assert!(can(Some(&writer), rep, Capability::SecurityAlerts));
1049 }
1050
1051 #[test]
1052 fn new_workspaces_start_at_read() {
1053 assert_eq!(BasePermission::FOR_NEW_WORKSPACES, BasePermission::Read);
1054 }
1055
The Packages page names every registry it has1056 #[test]
1057 fn anyone_reads_a_public_repository_and_nothing_more() {
1058 assert_eq!(permission(None, repo("rep_1", "acme", false)), Some(RepoRole::Read));
1059 assert_eq!(permission(None, repo("rep_1", "acme", true)), None);
1060 assert!(can(None, repo("rep_1", "acme", false), Capability::Read));
1061 assert!(!can(None, repo("rep_1", "acme", false), Capability::Triage));
1062 let stranger = user(&[("globex", Role::Owner, None)], &[]);
1063 assert_eq!(check(Some(&stranger), repo("rep_1", "acme", false), Capability::Push), Err(Denied::Forbidden));
1064 }
1065
1066 #[test]
1067 fn a_workspace_token_has_admin_in_its_workspace_only() {
1068 let token = User {
1069 id: "wsp_1".into(),
1070 username: "acme".into(),
1071 kind: PrincipalKind::Workspace,
1072 workspaces: vec![Membership::member("acme")],
1073 ..User::default()
1074 };
Workflow files need workflow_files:write from a token; fine-grained permission table1075 // Acting as the workspace with no token: a service, which does what
1076 // an owner can.
The Packages page names every registry it has1077 assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
1078 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
1079 }
1080
1081 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1082 fn a_workspace_token_has_write_unless_an_owner_gave_it_admin() {
1083 let mut token = User {
1084 id: "wsp_1".into(),
1085 username: "acme".into(),
1086 kind: PrincipalKind::Workspace,
1087 workspaces: vec![Membership::member("acme")],
1088 token: Some(Box::new(crate::scopes::TokenAccess::full())),
1089 ..User::default()
1090 };
1091 let private = repo("rep_1", "acme", true);
1092 assert_eq!(permission(Some(&token), private), Some(RepoRole::Write));
1093 assert!(can(Some(&token), private, Capability::Push));
1094 assert!(can(Some(&token), private, Capability::Merge));
1095 assert!(!can(Some(&token), private, Capability::ManageIntegrations), "webhooks, secrets and deploy keys are an admin's");
1096 assert!(!can(Some(&token), private, Capability::ManageAccess));
1097 token.token.as_mut().unwrap().admin = true;
1098 assert_eq!(permission(Some(&token), private), Some(RepoRole::Admin));
1099 assert!(can(Some(&token), private, Capability::ManageIntegrations));
1100 // Never deleting: that needs an owner, as a person.
1101 assert!(!can(Some(&token), private, Capability::Delete));
1102 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
1103 }
1104
1105 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1106 fn a_narrowed_token_has_a_role_only_inside_its_reach() {
1107 use crate::scopes::{RepositorySelection, TokenAccess, TokenReach};
Workflow files need workflow_files:write from a token; fine-grained permission table1108 let mut person = user(&[("acme", Role::Owner, None), ("globex", Role::Member, None)], &[("rep_9", "initech", RepoRole::Write)]);
1109 let reach = |workspace: Option<&str>, repositories, ids: &[&str]| {
1110 Some(Box::new(TokenAccess {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1111 reach: Some(TokenReach { workspace: workspace.map(str::to_owned), repositories, repo_ids: ids.iter().map(|id| (*id).to_owned()).collect() }),
Workflow files need workflow_files:write from a token; fine-grained permission table1112 ..TokenAccess::default()
1113 }))
1114 };
1115 let web = repo("rep_1", "acme", true);
1116 let api = repo("rep_2", "acme", true);
1117 let site = repo("rep_3", "acme", false);
1118 let elsewhere = repo("rep_4", "globex", true);
1119 person.token = reach(Some("acme"), RepositorySelection::All, &[]);
1120 assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1121 assert_eq!(permission(Some(&person), elsewhere), None, "only the workspace it is made for");
Workflow files need workflow_files:write from a token; fine-grained permission table1122 assert_eq!(permission(Some(&person), repo("rep_9", "initech", true)), None, "nor where its owner collaborates");
1123 person.token = reach(Some("acme"), RepositorySelection::Selected, &["rep_1"]);
1124 assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
1125 assert_eq!(permission(Some(&person), api), None);
1126 // A public repository it does not reach still reads, and nothing more.
1127 assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
1128 assert!(can(Some(&person), site, Capability::Read));
1129 assert!(!can(Some(&person), site, Capability::Participate));
1130 person.token = reach(Some("acme"), RepositorySelection::Public, &[]);
1131 assert_eq!(permission(Some(&person), web), None);
1132 assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
1133 person.token = reach(None, RepositorySelection::All, &[]);
1134 assert_eq!(permission(Some(&person), web), None, "your own account reaches no workspace's repositories");
1135 // A classic token reaches whatever its owner can.
1136 person.token = Some(Box::new(TokenAccess::full()));
1137 assert_eq!(permission(Some(&person), elsewhere), Some(RepoRole::Write));
1138 }
1139
1140 #[test]
The Packages page names every registry it has1141 fn roles_and_base_permissions_read_and_write_as_words() {
1142 for role in RepoRole::ALL {
1143 assert_eq!(RepoRole::parse(role.as_str()), Some(role));
1144 assert_eq!(serde_json::to_value(role).unwrap(), role.as_str());
1145 }
1146 for base in BasePermission::ALL {
1147 assert_eq!(BasePermission::parse(base.as_str()), Some(base));
1148 assert_eq!(serde_json::to_value(base).unwrap(), base.as_str());
1149 }
1150 for row in CAPABILITIES {
1151 assert_eq!(serde_json::to_value(row.capability).unwrap(), row.capability.as_str());
1152 }
1153 assert!(RepoRole::Read < RepoRole::Triage && RepoRole::Maintain < RepoRole::Admin);
1154 }
1155
1156 /// `packages/contracts/src/access.ts` lists the same table, in the
1157 /// same order, with the same least roles.
1158 #[test]
1159 fn the_typescript_mirror_has_the_same_table() {
1160 let ts = include_str!("../../../packages/contracts/src/access.ts");
1161 let table = ts
1162 .split_once("export const CAPABILITIES = [")
1163 .and_then(|(_, rest)| rest.split_once("] as const"))
1164 .map(|(table, _)| table)
1165 .expect("CAPABILITIES in access.ts");
1166 let rows: Vec<(String, String)> = table
1167 .lines()
1168 .filter_map(|line| {
1169 let capability = line.split_once("capability: \"")?.1.split_once('"')?.0;
1170 let role = line.split_once("role: \"")?.1.split_once('"')?.0;
1171 Some((capability.to_owned(), role.to_owned()))
1172 })
1173 .collect();
1174 let expected: Vec<(String, String)> = CAPABILITIES
1175 .iter()
1176 .map(|row| (row.capability.as_str().to_owned(), row.role.as_str().to_owned()))
1177 .collect();
1178 assert_eq!(rows, expected);
1179 let owner_only = ts
1180 .split_once("export const OWNER_ONLY = [")
1181 .and_then(|(_, rest)| rest.split_once(']'))
1182 .map(|(list, _)| list)
1183 .expect("OWNER_ONLY in access.ts");
1184 let mirrored: Vec<&str> = owner_only
1185 .split(',')
1186 .map(|item| item.trim().trim_matches('"'))
1187 .filter(|item| !item.is_empty())
1188 .collect();
1189 let expected: Vec<&str> = OWNER_ONLY.iter().map(|capability| capability.as_str()).collect();
1190 assert_eq!(mirrored, expected);
1191 }
1192}

This file's history is long; its oldest lines are credited to the oldest commit read.