Skip to content
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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24//! - **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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30//!
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
38//! Admin when making it ([`crate::scopes::TokenAccess::admin`]); what is
39//! for people only stays refused by the checks that say so. A
40//! fine-grained personal token has no role outside its resource owner and
41//! repository selection ([`crate::scopes::FineGrainedReach`]): there it
42//! reads public repositories, as anyone may, and nothing more. An
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43//! 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 /// 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`].
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look113#[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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look131 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look174 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look184 /// 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look191 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look195 /// 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look207 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",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220 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",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look224 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",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look228 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] = [
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look248 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" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look251 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" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look255 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" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look259 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)" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look264];
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}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look286
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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look311}
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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily344 if matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look358 }
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> {
Token reach: workflow_files scope, fine-grained reach, workspace token cap363 // A fine-grained token outside its resource owner, or its repository
364 // 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look368 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look399pub 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 };
Token reach: workflow_files scope, fine-grained reach, workspace token cap404 // Where a fine-grained token does not reach, it only reads.
405 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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look413 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));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look416 }
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 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look425 }
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look438/// 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 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look473 }
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look508}
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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look531}
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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look581 /// 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look776 })
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look785 })
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]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look803 (
804 RepoRole::Maintain,
Merge main (membership, two-factor, GitHub repo roles) into tokens805 &[Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run, ManageSettings],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look806 ),
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look813 ],
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]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look907 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1056 #[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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1077 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]
1106 fn a_fine_grained_token_has_a_role_only_inside_its_reach() {
1107 use crate::scopes::{FineGrainedReach, RepositorySelection, TokenAccess};
1108 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 {
1111 fine_grained: Some(FineGrainedReach { workspace: workspace.map(str::to_owned), repositories, repo_ids: ids.iter().map(|id| (*id).to_owned()).collect() }),
1112 ..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));
1121 assert_eq!(permission(Some(&person), elsewhere), None, "only its resource owner");
1122 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]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1141 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.