Skip to content

g1t/crates/contracts/src/access.rs

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