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.

1,339 linesCodeBlameRaw
1//! 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
16//! 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;
24//! - **public**: anyone, signed in or not, can read a public repository.
25//!
26//! - **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.
30//!
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//!
36//! **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//! 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
42//! reads public repositories, as anyone may, and nothing more. An
43//! 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 work items and pull requests, and comment.
58 Read,
59 /// Read, and manage work items and pull requests: label, assign, close.
60 Triage,
61 /// Triage, and push, merge, and put agents to work.
62 Write,
63 /// Write, and manage the repository's settings and topics.
64 Maintain,
65 /// Everything: branch protection and rulesets, webhooks, secrets,
66 /// deployments, security settings, who has access, and the
67 /// 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.
109///
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`].
113#[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 {
127 /// 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
131 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, work items and pull requests; clone and fetch.
169 Read,
170 /// Open work items and pull requests, and comment on them.
171 Participate,
172 /// Apply labels and milestones; assign, close and reopen work items
173 /// and pull requests; ask for reviews.
174 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,
179 /// 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,
184 /// Assign agents, start runs, plans and workflows: anything that
185 /// spends compute.
186 Run,
187 /// Change the topics, website, and how pull requests and
188 /// agents work.
189 ManageSettings,
190 /// Change branch protection, rulesets and guardrails.
191 ManageProtection,
192 /// Change security settings: scanning, push protection, custom
193 /// patterns and bypass reviews.
194 ManageSecurity,
195 /// Manage webhooks, secrets and variables, deployments, domains and
196 /// integrations.
197 ManageIntegrations,
198 /// Add, change and remove who has access, and invitations.
199 ManageAccess,
200 /// Rename, archive, and change the default branch.
201 Administer,
202 /// 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.
207 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",
218 Capability::ManageLabels => "manage_labels",
219 Capability::SecurityAlerts => "security_alerts",
220 Capability::Run => "run",
221 Capability::ManageSettings => "manage_settings",
222 Capability::ManageProtection => "manage_protection",
223 Capability::ManageSecurity => "manage_security",
224 Capability::ManageIntegrations => "manage_integrations",
225 Capability::ManageAccess => "manage_access",
226 Capability::Administer => "administer",
227 Capability::ChangeVisibility => "change_visibility",
228 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
244/// 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] = [
248 CapabilityRow { capability: Capability::Read, role: RepoRole::Read, about: "See code, work items and pull requests; clone and fetch" },
249 CapabilityRow { capability: Capability::Participate, role: RepoRole::Read, about: "Open work items and pull requests, and comment" },
250 CapabilityRow { capability: Capability::Triage, role: RepoRole::Triage, about: "Apply labels and milestones; assign, close and reopen work items and pull requests" },
251 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" },
253 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" },
255 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 topics, website, and pull request and agent settings" },
257 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" },
259 CapabilityRow { capability: Capability::ManageIntegrations, role: RepoRole::Admin, about: "Manage webhooks, secrets, variables, deployments and domains" },
260 CapabilityRow { capability: Capability::ManageAccess, role: RepoRole::Admin, about: "Manage who has access, invitations and deploy keys" },
261 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)" },
264];
265
266/// Capabilities that also need an owner of the repository's workspace,
267/// 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}
286
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,
308 /// 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>,
311}
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> {
334 // 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 }
344 if matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) {
345 return Some(RepoRole::Admin);
346 }
347 match membership.role {
348 Role::Owner => Some(RepoRole::Admin),
349 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 }
358 }
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> {
363 // A token outside the workspace it is made for, 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 }
368 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
396/// 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.
399pub 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 };
404 // Where a narrowed 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 }
412 let namespace = repo.namespace.to_lowercase();
413 if !allows(role, capability) {
414 return SECURITY_MANAGER.contains(&capability)
415 && viewer.is_some_and(|user| is_security_manager(user, &namespace));
416 }
417 if OWNER_ONLY.contains(&capability) {
418 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 });
425 }
426 true
427}
428
429/// 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
438/// 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// --- Work items -------------------------------------------------------------
466
467/// What a membership of `workspace` gives on its work items: what it gives
468/// on its repositories, and at least reading and taking part, since every
469/// member sees the workspace's work. A narrowed token that does not reach
470/// the whole workspace gives none.
471pub fn workspace_role(user: &User, workspace: &str) -> Option<RepoRole> {
472 if user.token.as_deref().is_some_and(|token| !token.covers_workspace(workspace)) {
473 return None;
474 }
475 let membership = user.workspaces.iter().find(|membership| membership.slug.eq_ignore_ascii_case(workspace))?;
476 membership_role(user, membership).max(Some(RepoRole::Read))
477}
478
479/// The viewer's role on a work item of `workspace` filed from `origin`
480/// (none for one filed in the workspace itself): the better of what their
481/// membership of the workspace gives ([`workspace_role`]) and their role
482/// on the origin ([`permission`]). So members see all of a workspace's
483/// work items, anyone who can read a repository sees those filed there
484/// (on a public one, everyone), and one with no origin is for members
485/// only. `None` means they may not see it at all.
486pub fn item_role(viewer: Option<&User>, workspace: &str, origin: Option<RepoRef<'_>>) -> Option<RepoRole> {
487 let member = viewer.and_then(|user| workspace_role(user, workspace));
488 let repo = origin.and_then(|origin| permission(viewer, origin));
489 member.max(repo)
490}
491
492/// Whether the viewer may do `capability` on a work item, by the same
493/// table as [`can`]: through their membership of the workspace, or their
494/// role on its origin as [`can`] reads it.
495pub fn can_item(viewer: Option<&User>, workspace: &str, origin: Option<RepoRef<'_>>, capability: Capability) -> bool {
496 let member = viewer.and_then(|user| workspace_role(user, workspace));
497 if member.is_some_and(|role| allows(role, capability)) {
498 return true;
499 }
500 origin.is_some_and(|origin| can(viewer, origin, capability))
501}
502
503/// `Ok` when the viewer may do `capability` on a work item; otherwise
504/// whether to answer not found (they may not see it) or forbidden.
505pub fn check_item(viewer: Option<&User>, workspace: &str, origin: Option<RepoRef<'_>>, capability: Capability) -> Result<(), Denied> {
506 if item_role(viewer, workspace, origin).is_none() {
507 return Err(Denied::NotFound);
508 }
509 if can_item(viewer, workspace, origin, capability) {
510 Ok(())
511 } else {
512 Err(Denied::Forbidden)
513 }
514}
515
516/// The sentence a refusal of `capability` on a work item of `workspace`
517/// gives.
518pub fn needs_on_item(capability: Capability, workspace: &str) -> String {
519 format!("You need the {} role or higher in {workspace} to do that.", least_role(capability).label())
520}
521
522/// The sentence a refusal of `capability` gives.
523pub fn needs(capability: Capability, repo: &str) -> String {
524 let role = least_role(capability);
525 if OWNER_ONLY.contains(&capability) {
526 return format!(
527 "Only an owner of the workspace can do that to {repo}, unless its member privileges let members with the {} role do it.",
528 role.label()
529 );
530 }
531 format!(
532 "You need the {} role or higher on {repo} to do that.",
533 role.label()
534 )
535}
536
537/// Whether the user has any way into the workspace `namespace`: a member,
538/// or someone with a role on one of its repositories.
539pub fn has_access_in(user: &User, namespace: &str) -> bool {
540 let namespace = namespace.to_lowercase();
541 user.is_member(&namespace) || user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
542}
543
544/// Whether the user is an outside collaborator of `namespace`: they have
545/// roles on some of its repositories without belonging to it.
546pub fn is_outside_collaborator(user: &User, namespace: &str) -> bool {
547 let namespace = namespace.to_lowercase();
548 !user.is_member(&namespace) && user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
549}
550
551// --- Who has access ---------------------------------------------------------
552
553/// How a person has their role on a repository.
554#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "snake_case")]
556pub enum AccessSource {
557 /// An owner of the workspace: Admin on everything in it.
558 Owner,
559 /// A member, through the workspace's base permission.
560 Base,
561 /// Given a role on this repository directly.
562 Direct,
563 /// In a team given a role on this repository, or in a child of one.
564 Team,
565}
566
567/// One person with access to a repository.
568#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
569pub struct Collaborator {
570 pub username: String,
571 /// Their display name, if they set one.
572 pub name: Option<String>,
573 pub avatar: Option<String>,
574 /// Their effective role: the highest of what they have.
575 pub role: RepoRole,
576 /// Where the effective role comes from.
577 pub source: AccessSource,
578 /// Their direct grant on this repository, if any (even when the base
579 /// permission or ownership gives more).
580 pub direct: Option<RepoRole>,
581 /// `owner`, `member`, or null for an outside collaborator.
582 pub workspace_role: Option<Role>,
583 /// The highest role a team gives them here, and that team's slug.
584 #[serde(default)]
585 pub team_role: Option<RepoRole>,
586 #[serde(default)]
587 pub team: Option<String>,
588}
589
590/// Where an invitation to a repository stands.
591#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "snake_case")]
593pub enum RepoInvitationStatus {
594 Pending,
595 Accepted,
596 Declined,
597 Revoked,
598 Expired,
599}
600
601/// An invitation to collaborate on one repository.
602#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
603pub struct RepoInvitation {
604 pub id: String,
605 /// `workspace/name`, as it is now.
606 pub repo: String,
607 pub repo_id: String,
608 /// Who is invited, when they have an account.
609 pub invitee: Option<String>,
610 /// The address it was sent to, when they had no account yet. Shown
611 /// only to whoever may manage the repository's access.
612 pub email: Option<String>,
613 pub role: RepoRole,
614 /// Who sent it, by username.
615 pub invited_by: Option<String>,
616 /// The avatar of who sent it: the SHA-256 of its bytes, served at
617 /// `/avatars/<avatar>`. None means the generated letter avatar.
618 #[serde(default)]
619 pub inviter_avatar: Option<String>,
620 pub status: RepoInvitationStatus,
621 /// RFC 3339.
622 pub created_at: String,
623 /// RFC 3339.
624 pub expires_at: String,
625}
626
627/// Who has access to a repository, as its Access settings show it.
628#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
629pub struct RepoAccess {
630 pub repo: String,
631 pub base_permission: BasePermission,
632 /// Everyone with access other than through the repository being
633 /// public: owners, members with a base role, and direct grants.
634 pub people: Vec<Collaborator>,
635 /// The workspace's teams given a role on it.
636 #[serde(default)]
637 pub teams: Vec<crate::teams::RepoTeam>,
638 /// Pending invitations. Empty unless the viewer may manage access.
639 pub invitations: Vec<RepoInvitation>,
640 /// The viewer's own role, and whether they may change who has access.
641 pub viewer_role: Option<RepoRole>,
642 pub can_manage: bool,
643}
644
645/// What adding someone did.
646#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
647#[serde(tag = "result", rename_all = "snake_case")]
648pub enum Added {
649 /// A member of the workspace: given the role at once.
650 Granted { collaborator: Collaborator },
651 /// Anyone else: sent an invitation to accept.
652 Invited { invitation: RepoInvitation },
653}
654
655/// A person's permission on a repository, as
656/// `GET /repos/{owner}/{name}/collaborators/{username}/permission` answers.
657#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
658pub struct PermissionInfo {
659 pub username: String,
660 /// Their role, or null when they have none (on a public repository
661 /// everyone reads it, which this does not count).
662 pub role: Option<RepoRole>,
663 pub source: Option<AccessSource>,
664 /// What the role lets them do, from the permission table.
665 pub capabilities: Vec<Capability>,
666}
667
668/// The capabilities `role` has, in the table's order.
669pub fn capabilities_of(role: Option<RepoRole>) -> Vec<Capability> {
670 CAPABILITIES
671 .iter()
672 .filter(|row| role.is_some_and(|role| role >= row.role))
673 .map(|row| row.capability)
674 .collect()
675}
676
677/// An outside collaborator of a workspace, and what they can reach.
678#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
679pub struct OutsideCollaborator {
680 pub username: String,
681 pub name: Option<String>,
682 pub avatar: Option<String>,
683 pub repos: Vec<CollaboratorRepo>,
684}
685
686#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
687pub struct CollaboratorRepo {
688 /// `workspace/name`.
689 pub repo: String,
690 pub role: RepoRole,
691}
692
693// --- Identity methods ---------------------------------------------------------
694//
695// Served by identity at `POST /rpc/<method>`. Each takes the repository's
696// path and the person asking; identity asks the repos service for the
697// repository as that person sees it, so a repository they cannot read is
698// not found, and one they can read without managing its access is
699// forbidden. Agents' tokens can never change access.
700
701/// `repo_access`: who has access to a repository. Needs Write (as the
702/// list of collaborators does); invitations need Admin.
703/// Returns `Outcome<RepoAccess>`.
704#[derive(Debug, Serialize, Deserialize)]
705pub struct RepoAccessArgs {
706 pub viewer: crate::Viewer,
707 pub path: RepoPath,
708}
709
710/// `add_collaborator`: gives `invitee` (a username or an email address)
711/// `role` on a repository. A member of its workspace gets it at once; a
712/// person with an account is sent an invitation to accept; an address
713/// without one is sent an invite code that makes their account and
714/// accepts. Admin only. Returns `Outcome<Added>`.
715#[derive(Debug, Serialize, Deserialize)]
716pub struct AddCollaboratorArgs {
717 pub actor: User,
718 pub path: RepoPath,
719 pub invitee: String,
720 pub role: RepoRole,
721 #[serde(default)]
722 pub surface: Option<crate::audit::Surface>,
723}
724
725/// `set_collaborator_role`: changes a direct grant, or a pending
726/// invitation's role. Admin only. Returns `Outcome<Collaborator>`.
727#[derive(Debug, Serialize, Deserialize)]
728pub struct SetCollaboratorRoleArgs {
729 pub actor: User,
730 pub path: RepoPath,
731 pub username: String,
732 pub role: RepoRole,
733 #[serde(default)]
734 pub surface: Option<crate::audit::Surface>,
735}
736
737/// `remove_collaborator`: takes away a direct grant. Admin only; anyone
738/// may remove themselves. Owners and the base permission are not changed
739/// here. Returns `Outcome<bool>`.
740#[derive(Debug, Serialize, Deserialize)]
741pub struct RemoveCollaboratorArgs {
742 pub actor: User,
743 pub path: RepoPath,
744 pub username: String,
745 #[serde(default)]
746 pub surface: Option<crate::audit::Surface>,
747}
748
749/// `collaborator_permission`: `username`'s role on a repository. Needs
750/// Write, or to be asking about yourself. Returns `Outcome<PermissionInfo>`.
751#[derive(Debug, Serialize, Deserialize)]
752pub struct CollaboratorPermissionArgs {
753 pub viewer: crate::Viewer,
754 pub path: RepoPath,
755 pub username: String,
756}
757
758/// `can_read_repo`: whether the person `user_id` may read `repo` now, by
759/// their workspace membership, teams, direct grants and the workspace's
760/// policy, or because it is public. For other services, which hold the
761/// repository already: the repos service asks it of a private fork's
762/// parent for whoever made the fork. Returns `bool`.
763#[derive(Debug, Serialize, Deserialize)]
764#[serde(rename_all = "camelCase")]
765pub struct CanReadRepoArgs {
766 pub repo: crate::repos::Repo,
767 pub user_id: String,
768}
769
770/// `my_repo_invitations`: the invitations waiting for `user` to answer.
771/// Returns `Vec<RepoInvitation>`.
772#[derive(Debug, Serialize, Deserialize)]
773pub struct MyRepoInvitationsArgs {
774 pub user: User,
775}
776
777/// `respond_repo_invitation`: accept or decline an invitation sent to you.
778/// Accepting is checked against the workspace's policy. Returns
779/// `Outcome<RepoInvitation>`.
780#[derive(Debug, Serialize, Deserialize)]
781pub struct RespondRepoInvitationArgs {
782 pub user: User,
783 pub id: String,
784 pub accept: bool,
785}
786
787/// `revoke_repo_invitation`: withdraw a pending invitation. Admin only.
788/// Returns `Outcome<RepoInvitation>`.
789#[derive(Debug, Serialize, Deserialize)]
790pub struct RevokeRepoInvitationArgs {
791 pub actor: User,
792 pub path: RepoPath,
793 pub id: String,
794 #[serde(default)]
795 pub surface: Option<crate::audit::Surface>,
796}
797
798/// `set_base_permission`: what every member gets on every repository.
799/// Owners only, as a person. Returns `Outcome<BasePermission>`.
800#[derive(Debug, Serialize, Deserialize)]
801pub struct SetBasePermissionArgs {
802 pub actor: User,
803 pub slug: String,
804 pub base_permission: BasePermission,
805 #[serde(default)]
806 pub surface: Option<crate::audit::Surface>,
807}
808
809/// `outside_collaborators`: the people with roles on a workspace's
810/// repositories who are not its members. Owners only. Returns
811/// `Outcome<Vec<OutsideCollaborator>>`.
812#[derive(Debug, Serialize, Deserialize)]
813pub struct OutsideCollaboratorsArgs {
814 pub viewer: crate::Viewer,
815 pub slug: String,
816}
817
818/// `forget_repo_access`: a repository was purged; its grants and
819/// invitations go with it. For the repos service. Returns `bool`.
820#[derive(Debug, Serialize, Deserialize)]
821pub struct ForgetRepoAccessArgs {
822 pub repo_id: String,
823}
824
825#[cfg(test)]
826mod item_tests {
827 use super::*;
828 use crate::scopes::{RepositorySelection, TokenAccess, TokenReach};
829
830 fn person(memberships: &[(&str, Role, Option<BasePermission>)], grants: &[(&str, &str, RepoRole)]) -> User {
831 User {
832 id: "usr_1".into(),
833 username: "ana".into(),
834 verified: true,
835 workspaces: memberships
836 .iter()
837 .map(|(slug, role, base)| Membership { role: *role, base_permission: *base, ..Membership::member(*slug) })
838 .collect(),
839 grants: grants
840 .iter()
841 .map(|(id, workspace, role)| RepoGrant { repo_id: (*id).into(), workspace: (*workspace).into(), role: *role, team: None })
842 .collect(),
843 ..User::default()
844 }
845 }
846
847 fn origin(private: bool) -> Option<RepoRef<'static>> {
848 Some(RepoRef { id: "rep_1", namespace: "acme", private })
849 }
850
851 #[test]
852 fn members_see_every_work_item_of_their_workspace() {
853 let member = person(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
854 assert_eq!(item_role(Some(&member), "acme", None), Some(RepoRole::Read));
855 assert_eq!(item_role(Some(&member), "acme", origin(true)), Some(RepoRole::Read));
856 assert!(can_item(Some(&member), "acme", None, Capability::Participate));
857 assert!(!can_item(Some(&member), "acme", None, Capability::Triage));
858 let writer = person(&[("acme", Role::Member, Some(BasePermission::Write))], &[]);
859 assert!(can_item(Some(&writer), "ACME", None, Capability::ManageLabels));
860 let owner = person(&[("acme", Role::Owner, None)], &[]);
861 assert_eq!(item_role(Some(&owner), "acme", None), Some(RepoRole::Admin));
862 // Another workspace's member sees none of them.
863 let other = person(&[("globex", Role::Owner, None)], &[]);
864 assert_eq!(check_item(Some(&other), "acme", None, Capability::Read), Err(Denied::NotFound));
865 }
866
867 #[test]
868 fn anyone_who_can_read_the_origin_sees_what_was_filed_there() {
869 // A public repository's work items are public, and anyone signed
870 // in may take part in them, as in the repository.
871 assert_eq!(item_role(None, "acme", origin(false)), Some(RepoRole::Read));
872 let outsider = person(&[], &[]);
873 assert!(can_item(Some(&outsider), "acme", origin(false), Capability::Participate));
874 assert_eq!(check_item(Some(&outsider), "acme", origin(false), Capability::Triage), Err(Denied::Forbidden));
875 // A private one's are for those it is shared with.
876 assert_eq!(item_role(Some(&outsider), "acme", origin(true)), None);
877 let collaborator = person(&[], &[("rep_1", "acme", RepoRole::Triage)]);
878 assert_eq!(item_role(Some(&collaborator), "acme", origin(true)), Some(RepoRole::Triage));
879 assert!(can_item(Some(&collaborator), "acme", origin(true), Capability::Triage));
880 // With no origin, only members.
881 assert_eq!(item_role(Some(&collaborator), "acme", None), None);
882 }
883
884 #[test]
885 fn a_narrowed_token_reaches_only_what_it_was_made_for() {
886 let mut member = person(&[("acme", Role::Member, Some(BasePermission::Write))], &[]);
887 member.token = Some(Box::new(TokenAccess {
888 reach: Some(TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_2".into()] }),
889 ..TokenAccess::full()
890 }));
891 // Not the whole workspace: no member's view of it.
892 assert_eq!(item_role(Some(&member), "acme", None), None);
893 // A public origin still reads.
894 assert_eq!(item_role(Some(&member), "acme", origin(false)), Some(RepoRole::Read));
895 member.token = Some(Box::new(TokenAccess {
896 reach: Some(TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() }),
897 ..TokenAccess::full()
898 }));
899 assert_eq!(item_role(Some(&member), "acme", None), Some(RepoRole::Write));
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906
907 fn user(memberships: &[(&str, Role, Option<BasePermission>)], grants: &[(&str, &str, RepoRole)]) -> User {
908 User {
909 id: "usr_1".into(),
910 username: "ana".into(),
911 verified: true,
912 workspaces: memberships
913 .iter()
914 .map(|(slug, role, base)| Membership {
915 slug: (*slug).into(),
916 role: *role,
917 name: None,
918 avatar: None,
919 base_permission: *base,
920 team_creation: None,
921 org_roles: Vec::new(),
922 privileges: None,
923 })
924 .collect(),
925 grants: grants
926 .iter()
927 .map(|(id, workspace, role)| RepoGrant {
928 repo_id: (*id).into(),
929 workspace: (*workspace).into(),
930 role: *role,
931 team: None,
932 })
933 .collect(),
934 ..User::default()
935 }
936 }
937
938 fn repo(id: &'static str, namespace: &'static str, private: bool) -> RepoRef<'static> {
939 RepoRef { id, namespace, private }
940 }
941
942 /// Every role against every capability: the whole table, spelled out.
943 #[test]
944 fn every_role_has_exactly_the_capabilities_of_the_table() {
945 use Capability::*;
946 let expected: [(RepoRole, &[Capability]); 5] = [
947 (RepoRole::Read, &[Read, Participate]),
948 (RepoRole::Triage, &[Read, Participate, Triage]),
949 (RepoRole::Write, &[Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run]),
950 (
951 RepoRole::Maintain,
952 &[Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run, ManageSettings],
953 ),
954 (
955 RepoRole::Admin,
956 &[
957 Read, Participate, Triage, Push, Merge, ManageLabels, SecurityAlerts, Run, ManageSettings,
958 ManageProtection, ManageSecurity, ManageIntegrations, ManageAccess, Administer, ChangeVisibility,
959 Delete,
960 ],
961 ),
962 ];
963 for (role, has) in expected {
964 for row in CAPABILITIES {
965 assert_eq!(
966 allows(role, row.capability),
967 has.contains(&row.capability),
968 "{} and {}",
969 role.as_str(),
970 row.capability.as_str()
971 );
972 }
973 assert_eq!(capabilities_of(Some(role)), has.to_vec());
974 }
975 assert!(capabilities_of(None).is_empty());
976 }
977
978 #[test]
979 fn read_cannot_spend_compute() {
980 assert!(!allows(RepoRole::Read, Capability::Run));
981 assert!(!allows(RepoRole::Triage, Capability::Run));
982 assert!(allows(RepoRole::Write, Capability::Run));
983 }
984
985 #[test]
986 fn owners_have_admin_on_everything_in_their_workspace() {
987 let owner = user(&[("acme", Role::Owner, Some(BasePermission::None))], &[]);
988 assert_eq!(permission(Some(&owner), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
989 assert!(can(Some(&owner), repo("rep_1", "acme", true), Capability::Delete));
990 }
991
992 #[test]
993 fn members_get_the_base_permission_and_write_when_unset() {
994 let unset = user(&[("acme", Role::Member, None)], &[]);
995 assert_eq!(permission(Some(&unset), repo("rep_1", "acme", true)), Some(RepoRole::Write));
996 let read = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
997 assert_eq!(permission(Some(&read), repo("rep_1", "acme", true)), Some(RepoRole::Read));
998 let none = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
999 assert_eq!(permission(Some(&none), repo("rep_1", "acme", true)), None);
1000 assert_eq!(permission(Some(&none), repo("rep_1", "acme", false)), Some(RepoRole::Read));
1001 }
1002
1003 /// The default keeps what members could do before roles: read, push,
1004 /// merge, run agents.
1005 #[test]
1006 fn the_default_base_permission_keeps_members_working() {
1007 assert_eq!(BasePermission::default(), BasePermission::Write);
1008 let member = user(&[("acme", Role::Member, None)], &[]);
1009 for capability in [Capability::Read, Capability::Participate, Capability::Push, Capability::Merge, Capability::Run] {
1010 assert!(can(Some(&member), repo("rep_1", "acme", true), capability));
1011 }
1012 // Transfer and delete were owners' only, and still are.
1013 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::Delete));
1014 }
1015
1016 #[test]
1017 fn effective_permission_is_the_highest_source() {
1018 let member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Maintain)]);
1019 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
1020 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
1021 // A grant lower than the base changes nothing.
1022 let member = user(&[("acme", Role::Member, Some(BasePermission::Admin))], &[("rep_1", "acme", RepoRole::Read)]);
1023 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
1024 }
1025
1026 #[test]
1027 fn a_teams_grant_counts_like_any_other_and_the_highest_wins() {
1028 let mut member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
1029 member.grants.push(RepoGrant {
1030 repo_id: "rep_1".into(),
1031 workspace: "acme".into(),
1032 role: RepoRole::Maintain,
1033 team: Some("backend".into()),
1034 });
1035 member.grants.push(RepoGrant {
1036 repo_id: "rep_1".into(),
1037 workspace: "acme".into(),
1038 role: RepoRole::Triage,
1039 team: None,
1040 });
1041 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
1042 assert!(can(Some(&member), repo("rep_1", "acme", true), Capability::ManageSettings));
1043 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::ManageProtection));
1044 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::ManageAccess));
1045 // Elsewhere, only the base.
1046 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
1047 // Serialized without `team` when it is a person's own.
1048 let own = serde_json::to_value(&member.grants[1]).unwrap();
1049 assert!(own.get("team").is_none());
1050 assert_eq!(serde_json::to_value(&member.grants[0]).unwrap()["team"], "backend");
1051 }
1052
1053 #[test]
1054 fn outside_collaborators_reach_only_their_repositories() {
1055 let outsider = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
1056 assert_eq!(permission(Some(&outsider), repo("rep_1", "acme", true)), Some(RepoRole::Triage));
1057 assert_eq!(permission(Some(&outsider), repo("rep_2", "acme", true)), None);
1058 assert_eq!(check(Some(&outsider), repo("rep_2", "acme", true), Capability::Read), Err(Denied::NotFound));
1059 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Push), Err(Denied::Forbidden));
1060 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Triage), Ok(()));
1061 assert!(is_outside_collaborator(&outsider, "acme"));
1062 assert!(has_access_in(&outsider, "acme"));
1063 assert!(!has_access_in(&outsider, "globex"));
1064 }
1065
1066 #[test]
1067 fn a_direct_admin_cannot_transfer_or_delete() {
1068 let admin = user(&[], &[("rep_1", "acme", RepoRole::Admin)]);
1069 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::Administer));
1070 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::ManageAccess));
1071 assert!(!can(Some(&admin), repo("rep_1", "acme", true), Capability::Delete));
1072 }
1073
1074 /// GitHub's table, row by row where g1t has the action: the least role
1075 /// each one takes there.
1076 #[test]
1077 fn the_table_matches_githubs_repository_roles() {
1078 let github: [(Capability, RepoRole); 16] = [
1079 // "Pull from the repository", "View ..."
1080 (Capability::Read, RepoRole::Read),
1081 // "Open issues", "Comment on issues and pull requests"
1082 (Capability::Participate, RepoRole::Read),
1083 // "Apply/dismiss labels", "Apply milestones", "Close, reopen,
1084 // and assign all issues and pull requests"
1085 (Capability::Triage, RepoRole::Triage),
1086 // "Push to (write) the person or team's assigned repositories"
1087 (Capability::Push, RepoRole::Write),
1088 // "Merge pull requests"
1089 (Capability::Merge, RepoRole::Write),
1090 // "Create, edit, delete labels", "Create, edit, delete milestones"
1091 (Capability::ManageLabels, RepoRole::Write),
1092 // "Receive and dismiss Dependabot alerts", "List, dismiss, and
1093 // delete code scanning alerts", "View and dismiss secret
1094 // scanning alerts"
1095 (Capability::SecurityAlerts, RepoRole::Write),
1096 // "Create, edit, run, re-run, and cancel GitHub Actions workflows"
1097 (Capability::Run, RepoRole::Write),
1098 // "Edit a repository's description", "Manage topics", "Manage
1099 // pull request merges"
1100 (Capability::ManageSettings, RepoRole::Maintain),
1101 // "Manage branch protection rules and repository rulesets"
1102 (Capability::ManageProtection, RepoRole::Admin),
1103 // "Manage security and analysis features"
1104 (Capability::ManageSecurity, RepoRole::Admin),
1105 // "Manage webhooks and deploy keys"
1106 (Capability::ManageIntegrations, RepoRole::Admin),
1107 // "Manage individual and team access to the repository"
1108 (Capability::ManageAccess, RepoRole::Admin),
1109 // "Rename a repository", "Archive repositories", "Change the
1110 // default branch"
1111 (Capability::Administer, RepoRole::Admin),
1112 // "Change a repository's visibility"
1113 (Capability::ChangeVisibility, RepoRole::Admin),
1114 // "Delete or transfer repositories"
1115 (Capability::Delete, RepoRole::Admin),
1116 ];
1117 for (capability, role) in github {
1118 assert_eq!(least_role(capability), role, "{}", capability.as_str());
1119 }
1120 assert_eq!(github.len(), CAPABILITIES.len());
1121 }
1122
1123 fn with_privileges(mut person: User, privileges: crate::MemberPrivileges) -> User {
1124 for membership in &mut person.workspaces {
1125 membership.privileges = Some(privileges);
1126 }
1127 person
1128 }
1129
1130 #[test]
1131 fn member_privileges_decide_whether_admins_change_visibility_delete_and_transfer() {
1132 let admin = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Admin)]);
1133 let rep = repo("rep_1", "acme", true);
1134 // The defaults: admins change visibility; only owners delete.
1135 assert!(can(Some(&admin), rep, Capability::ChangeVisibility));
1136 assert!(!can(Some(&admin), rep, Capability::Delete));
1137 let open = with_privileges(
1138 admin.clone(),
1139 crate::MemberPrivileges { members_can_delete_repositories: true, ..crate::MemberPrivileges::default() },
1140 );
1141 assert!(can(Some(&open), rep, Capability::Delete));
1142 let closed = with_privileges(
1143 admin.clone(),
1144 crate::MemberPrivileges { members_can_change_repo_visibility: false, ..crate::MemberPrivileges::default() },
1145 );
1146 assert!(!can(Some(&closed), rep, Capability::ChangeVisibility));
1147 // A writer never can, whatever the privileges.
1148 let writer = with_privileges(
1149 user(&[("acme", Role::Member, Some(BasePermission::Write))], &[]),
1150 crate::MemberPrivileges { members_can_delete_repositories: true, ..crate::MemberPrivileges::default() },
1151 );
1152 assert!(!can(Some(&writer), rep, Capability::Delete));
1153 // Owners always can.
1154 let owner = with_privileges(
1155 user(&[("acme", Role::Owner, None)], &[]),
1156 crate::MemberPrivileges { members_can_change_repo_visibility: false, ..crate::MemberPrivileges::default() },
1157 );
1158 assert!(can(Some(&owner), rep, Capability::ChangeVisibility));
1159 assert!(can(Some(&owner), rep, Capability::Delete));
1160 }
1161
1162 #[test]
1163 fn a_security_manager_reads_everything_and_manages_its_security_only() {
1164 let mut manager = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
1165 manager.workspaces[0].org_roles.push(crate::OrgRole::SecurityManager);
1166 let rep = repo("rep_1", "acme", true);
1167 assert_eq!(permission(Some(&manager), rep), Some(RepoRole::Read));
1168 for capability in SECURITY_MANAGER {
1169 assert!(can(Some(&manager), rep, capability), "{}", capability.as_str());
1170 }
1171 for capability in [Capability::Triage, Capability::Push, Capability::ManageSettings, Capability::ManageProtection] {
1172 assert!(!can(Some(&manager), rep, capability), "{}", capability.as_str());
1173 }
1174 // Elsewhere, nothing.
1175 assert_eq!(permission(Some(&manager), repo("rep_2", "globex", true)), None);
1176 // A billing manager gets no repository access from the role.
1177 let mut billing = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
1178 billing.workspaces[0].org_roles.push(crate::OrgRole::BillingManager);
1179 assert_eq!(permission(Some(&billing), rep), None);
1180 assert!(billing.manages_billing("acme"));
1181 assert!(!billing.manages_security("acme"));
1182 }
1183
1184 #[test]
1185 fn a_maintainer_no_longer_changes_branch_protection() {
1186 let maintainer = user(&[], &[("rep_1", "acme", RepoRole::Maintain)]);
1187 let rep = repo("rep_1", "acme", true);
1188 assert!(can(Some(&maintainer), rep, Capability::ManageSettings));
1189 assert!(!can(Some(&maintainer), rep, Capability::ManageProtection));
1190 let triager = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
1191 assert!(can(Some(&triager), rep, Capability::Triage));
1192 assert!(!can(Some(&triager), rep, Capability::ManageLabels));
1193 let writer = user(&[], &[("rep_1", "acme", RepoRole::Write)]);
1194 assert!(can(Some(&writer), rep, Capability::ManageLabels));
1195 assert!(can(Some(&writer), rep, Capability::SecurityAlerts));
1196 }
1197
1198 #[test]
1199 fn new_workspaces_start_at_read() {
1200 assert_eq!(BasePermission::FOR_NEW_WORKSPACES, BasePermission::Read);
1201 }
1202
1203 #[test]
1204 fn anyone_reads_a_public_repository_and_nothing_more() {
1205 assert_eq!(permission(None, repo("rep_1", "acme", false)), Some(RepoRole::Read));
1206 assert_eq!(permission(None, repo("rep_1", "acme", true)), None);
1207 assert!(can(None, repo("rep_1", "acme", false), Capability::Read));
1208 assert!(!can(None, repo("rep_1", "acme", false), Capability::Triage));
1209 let stranger = user(&[("globex", Role::Owner, None)], &[]);
1210 assert_eq!(check(Some(&stranger), repo("rep_1", "acme", false), Capability::Push), Err(Denied::Forbidden));
1211 }
1212
1213 #[test]
1214 fn a_workspace_token_has_admin_in_its_workspace_only() {
1215 let token = User {
1216 id: "wsp_1".into(),
1217 username: "acme".into(),
1218 kind: PrincipalKind::Workspace,
1219 workspaces: vec![Membership::member("acme")],
1220 ..User::default()
1221 };
1222 // Acting as the workspace with no token: a service, which does what
1223 // an owner can.
1224 assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
1225 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
1226 }
1227
1228 #[test]
1229 fn a_workspace_token_has_write_unless_an_owner_gave_it_admin() {
1230 let mut token = User {
1231 id: "wsp_1".into(),
1232 username: "acme".into(),
1233 kind: PrincipalKind::Workspace,
1234 workspaces: vec![Membership::member("acme")],
1235 token: Some(Box::new(crate::scopes::TokenAccess::full())),
1236 ..User::default()
1237 };
1238 let private = repo("rep_1", "acme", true);
1239 assert_eq!(permission(Some(&token), private), Some(RepoRole::Write));
1240 assert!(can(Some(&token), private, Capability::Push));
1241 assert!(can(Some(&token), private, Capability::Merge));
1242 assert!(!can(Some(&token), private, Capability::ManageIntegrations), "webhooks, secrets and deploy keys are an admin's");
1243 assert!(!can(Some(&token), private, Capability::ManageAccess));
1244 token.token.as_mut().unwrap().admin = true;
1245 assert_eq!(permission(Some(&token), private), Some(RepoRole::Admin));
1246 assert!(can(Some(&token), private, Capability::ManageIntegrations));
1247 // Never deleting: that needs an owner, as a person.
1248 assert!(!can(Some(&token), private, Capability::Delete));
1249 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
1250 }
1251
1252 #[test]
1253 fn a_narrowed_token_has_a_role_only_inside_its_reach() {
1254 use crate::scopes::{RepositorySelection, TokenAccess, TokenReach};
1255 let mut person = user(&[("acme", Role::Owner, None), ("globex", Role::Member, None)], &[("rep_9", "initech", RepoRole::Write)]);
1256 let reach = |workspace: Option<&str>, repositories, ids: &[&str]| {
1257 Some(Box::new(TokenAccess {
1258 reach: Some(TokenReach { workspace: workspace.map(str::to_owned), repositories, repo_ids: ids.iter().map(|id| (*id).to_owned()).collect() }),
1259 ..TokenAccess::default()
1260 }))
1261 };
1262 let web = repo("rep_1", "acme", true);
1263 let api = repo("rep_2", "acme", true);
1264 let site = repo("rep_3", "acme", false);
1265 let elsewhere = repo("rep_4", "globex", true);
1266 person.token = reach(Some("acme"), RepositorySelection::All, &[]);
1267 assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
1268 assert_eq!(permission(Some(&person), elsewhere), None, "only the workspace it is made for");
1269 assert_eq!(permission(Some(&person), repo("rep_9", "initech", true)), None, "nor where its owner collaborates");
1270 person.token = reach(Some("acme"), RepositorySelection::Selected, &["rep_1"]);
1271 assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin));
1272 assert_eq!(permission(Some(&person), api), None);
1273 // A public repository it does not reach still reads, and nothing more.
1274 assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
1275 assert!(can(Some(&person), site, Capability::Read));
1276 assert!(!can(Some(&person), site, Capability::Participate));
1277 person.token = reach(Some("acme"), RepositorySelection::Public, &[]);
1278 assert_eq!(permission(Some(&person), web), None);
1279 assert_eq!(permission(Some(&person), site), Some(RepoRole::Read));
1280 person.token = reach(None, RepositorySelection::All, &[]);
1281 assert_eq!(permission(Some(&person), web), None, "your own account reaches no workspace's repositories");
1282 // A classic token reaches whatever its owner can.
1283 person.token = Some(Box::new(TokenAccess::full()));
1284 assert_eq!(permission(Some(&person), elsewhere), Some(RepoRole::Write));
1285 }
1286
1287 #[test]
1288 fn roles_and_base_permissions_read_and_write_as_words() {
1289 for role in RepoRole::ALL {
1290 assert_eq!(RepoRole::parse(role.as_str()), Some(role));
1291 assert_eq!(serde_json::to_value(role).unwrap(), role.as_str());
1292 }
1293 for base in BasePermission::ALL {
1294 assert_eq!(BasePermission::parse(base.as_str()), Some(base));
1295 assert_eq!(serde_json::to_value(base).unwrap(), base.as_str());
1296 }
1297 for row in CAPABILITIES {
1298 assert_eq!(serde_json::to_value(row.capability).unwrap(), row.capability.as_str());
1299 }
1300 assert!(RepoRole::Read < RepoRole::Triage && RepoRole::Maintain < RepoRole::Admin);
1301 }
1302
1303 /// `packages/contracts/src/access.ts` lists the same table, in the
1304 /// same order, with the same least roles.
1305 #[test]
1306 fn the_typescript_mirror_has_the_same_table() {
1307 let ts = include_str!("../../../packages/contracts/src/access.ts");
1308 let table = ts
1309 .split_once("export const CAPABILITIES = [")
1310 .and_then(|(_, rest)| rest.split_once("] as const"))
1311 .map(|(table, _)| table)
1312 .expect("CAPABILITIES in access.ts");
1313 let rows: Vec<(String, String)> = table
1314 .lines()
1315 .filter_map(|line| {
1316 let capability = line.split_once("capability: \"")?.1.split_once('"')?.0;
1317 let role = line.split_once("role: \"")?.1.split_once('"')?.0;
1318 Some((capability.to_owned(), role.to_owned()))
1319 })
1320 .collect();
1321 let expected: Vec<(String, String)> = CAPABILITIES
1322 .iter()
1323 .map(|row| (row.capability.as_str().to_owned(), row.role.as_str().to_owned()))
1324 .collect();
1325 assert_eq!(rows, expected);
1326 let owner_only = ts
1327 .split_once("export const OWNER_ONLY = [")
1328 .and_then(|(_, rest)| rest.split_once(']'))
1329 .map(|(list, _)| list)
1330 .expect("OWNER_ONLY in access.ts");
1331 let mirrored: Vec<&str> = owner_only
1332 .split(',')
1333 .map(|item| item.trim().trim_matches('"'))
1334 .filter(|item| !item.is_empty())
1335 .collect();
1336 let expected: Vec<&str> = OWNER_ONLY.iter().map(|capability| capability.as_str()).collect();
1337 assert_eq!(mirrored, expected);
1338 }
1339}