Skip to content

g1t/crates/contracts/src/access.rs

889 lines35,178 bytesCodeBlame

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

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Who may do what in a repository: repository roles, the capabilities
2//! each one carries, and how a person's permission is worked out.
3//!
4//! **One table.** [`CAPABILITIES`] says, for each [`Capability`], the
5//! least [`RepoRole`] that has it. Every service asks [`can`] (or
6//! [`permission`]) instead of checking membership itself, the site draws
7//! its Roles table from the same list, and
8//! `packages/contracts/src/access.ts` mirrors it (a test here reads that
9//! file and fails when the two differ).
10//!
11//! **Effective permission** is the highest of:
12//!
13//! - **ownership**: an owner of the repository's workspace has Admin on
14//! every repository in it;
15//! - **the base permission** of the workspace ([`BasePermission`]), which
16//! every member gets on every repository (Write unless an owner changes
17//! it);
18//! - **a direct grant** ([`RepoGrant`]) to the person, on that repository;
19//! - **public**: anyone, signed in or not, can read a public repository.
20//!
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar21//! - **a team's grant**: a role given to a team the person is in, or to
22//! one of that team's parents (see [`crate::teams`]). Identity resolves
23//! it into the same [`RepoGrant`]s, with [`RepoGrant::team`] set, so the
24//! rules here treat it as any other grant.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25//!
26//! Identity attaches a person's grants ([`User::grants`]) and each
27//! membership's base permission ([`Membership::base_permission`]) when it
28//! resolves them from credentials, so asking costs nothing: no call, and
29//! the answer is as fresh as the request.
30//!
31//! **Tokens.** A workspace's own token has Admin on its workspace's
32//! repositories, as it could do everything a member could before roles;
33//! what is for people only stays refused by the checks that say so. An
34//! agent's token carries the memberships and grants of the person it acts
35//! for, cut down to its repository's workspace
36//! (`credentials::intersect`), so it never has more than that person on
37//! that repository, and its scope limits it further.
38
39use serde::{Deserialize, Serialize};
40
41use crate::repos::{Repo, RepoPath};
42use crate::{Membership, PrincipalKind, Role, User};
43
44/// What someone may do in one repository, from least to most.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum RepoRole {
48 /// Read and clone; open issues and pull requests, and comment.
49 Read,
50 /// Read, and manage issues and pull requests: label, assign, close.
51 Triage,
52 /// Triage, and push, merge, and put agents to work.
53 Write,
54 /// Write, and manage the repository's settings and branch protection.
55 Maintain,
56 /// Everything: webhooks, secrets, deployments, who has access, and the
57 /// repository's name, visibility and archiving.
58 Admin,
59}
60
61impl RepoRole {
62 pub const ALL: [RepoRole; 5] = [
63 RepoRole::Read,
64 RepoRole::Triage,
65 RepoRole::Write,
66 RepoRole::Maintain,
67 RepoRole::Admin,
68 ];
69
70 pub fn as_str(self) -> &'static str {
71 match self {
72 RepoRole::Read => "read",
73 RepoRole::Triage => "triage",
74 RepoRole::Write => "write",
75 RepoRole::Maintain => "maintain",
76 RepoRole::Admin => "admin",
77 }
78 }
79
80 pub fn parse(text: &str) -> Option<RepoRole> {
81 RepoRole::ALL
82 .into_iter()
83 .find(|role| role.as_str() == text.trim().to_ascii_lowercase())
84 }
85
86 /// How people are shown it: "Read", "Triage"...
87 pub fn label(self) -> &'static str {
88 match self {
89 RepoRole::Read => "Read",
90 RepoRole::Triage => "Triage",
91 RepoRole::Write => "Write",
92 RepoRole::Maintain => "Maintain",
93 RepoRole::Admin => "Admin",
94 }
95 }
96}
97
98/// What every member of a workspace gets on each of its repositories.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum BasePermission {
102 /// Nothing beyond what is public: members see the private
103 /// repositories they are given access to, and no others.
104 None,
105 Read,
106 /// What members could do before roles: push, merge, run agents.
107 #[default]
108 Write,
109 Admin,
110}
111
112impl BasePermission {
113 pub const ALL: [BasePermission; 4] = [
114 BasePermission::None,
115 BasePermission::Read,
116 BasePermission::Write,
117 BasePermission::Admin,
118 ];
119
120 pub fn as_str(self) -> &'static str {
121 match self {
122 BasePermission::None => "none",
123 BasePermission::Read => "read",
124 BasePermission::Write => "write",
125 BasePermission::Admin => "admin",
126 }
127 }
128
129 pub fn parse(text: &str) -> Option<BasePermission> {
130 BasePermission::ALL
131 .into_iter()
132 .find(|base| base.as_str() == text.trim().to_ascii_lowercase())
133 }
134
135 /// The repository role it gives, if any.
136 pub fn role(self) -> Option<RepoRole> {
137 match self {
138 BasePermission::None => None,
139 BasePermission::Read => Some(RepoRole::Read),
140 BasePermission::Write => Some(RepoRole::Write),
141 BasePermission::Admin => Some(RepoRole::Admin),
142 }
143 }
144}
145
146/// Something that can be done in a repository.
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum Capability {
150 /// See the code, issues and pull requests; clone and fetch.
151 Read,
152 /// Open issues and pull requests, and comment on them.
153 Participate,
154 /// Label, assign, close and reopen issues and pull requests.
155 Triage,
156 /// Push to branches that are not protected, and edit files on the web.
157 Push,
158 /// Merge pull requests and manage the merge queue.
159 Merge,
160 /// Assign agents, start runs, plans and workflows: anything that
161 /// spends compute.
162 Run,
163 /// Change the description, topics, website, and how pull requests and
164 /// agents work.
165 ManageSettings,
166 /// Change branch protection and guardrails.
167 ManageProtection,
168 /// Manage webhooks, secrets and variables, deployments, domains and
169 /// integrations.
170 ManageIntegrations,
171 /// Add, change and remove who has access, and invitations.
172 ManageAccess,
173 /// Rename, archive, change visibility and the default branch.
174 Administer,
175 /// Transfer or delete the repository. Also needs an owner of its
176 /// workspace, as [`OWNER_ONLY`] says.
177 Delete,
178}
179
180impl Capability {
181 pub fn as_str(self) -> &'static str {
182 match self {
183 Capability::Read => "read",
184 Capability::Participate => "participate",
185 Capability::Triage => "triage",
186 Capability::Push => "push",
187 Capability::Merge => "merge",
188 Capability::Run => "run",
189 Capability::ManageSettings => "manage_settings",
190 Capability::ManageProtection => "manage_protection",
191 Capability::ManageIntegrations => "manage_integrations",
192 Capability::ManageAccess => "manage_access",
193 Capability::Administer => "administer",
194 Capability::Delete => "delete",
195 }
196 }
197}
198
199/// One row of the permission table.
200#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
201pub struct CapabilityRow {
202 pub capability: Capability,
203 /// The least role that has it.
204 pub role: RepoRole,
205 /// What it covers, as the Roles table shows it.
206 pub about: &'static str,
207}
208
209/// The permission table: the least role for each capability. The single
210/// source of truth; `packages/contracts/src/access.ts` mirrors it.
211pub const CAPABILITIES: [CapabilityRow; 12] = [
212 CapabilityRow { capability: Capability::Read, role: RepoRole::Read, about: "See code, issues and pull requests; clone and fetch" },
213 CapabilityRow { capability: Capability::Participate, role: RepoRole::Read, about: "Open issues and pull requests, and comment" },
214 CapabilityRow { capability: Capability::Triage, role: RepoRole::Triage, about: "Label, assign, close and reopen issues and pull requests" },
215 CapabilityRow { capability: Capability::Push, role: RepoRole::Write, about: "Push to branches that are not protected" },
216 CapabilityRow { capability: Capability::Merge, role: RepoRole::Write, about: "Merge pull requests and use the merge queue" },
217 CapabilityRow { capability: Capability::Run, role: RepoRole::Write, about: "Assign agents and start runs, plans and workflows" },
218 CapabilityRow { capability: Capability::ManageSettings, role: RepoRole::Maintain, about: "Change the description, topics, and pull request and agent settings" },
219 CapabilityRow { capability: Capability::ManageProtection, role: RepoRole::Maintain, about: "Change branch protection and guardrails" },
220 CapabilityRow { capability: Capability::ManageIntegrations, role: RepoRole::Admin, about: "Manage webhooks, secrets, variables, deployments and domains" },
221 CapabilityRow { capability: Capability::ManageAccess, role: RepoRole::Admin, about: "Manage who has access, and invitations" },
222 CapabilityRow { capability: Capability::Administer, role: RepoRole::Admin, about: "Rename, archive, change visibility and the default branch" },
223 CapabilityRow { capability: Capability::Delete, role: RepoRole::Admin, about: "Transfer or delete the repository (owners of the workspace only)" },
224];
225
226/// Capabilities that also need an owner of the repository's workspace,
227/// whatever a person's role on the repository.
228pub const OWNER_ONLY: [Capability; 1] = [Capability::Delete];
229
230/// The least role that has `capability`.
231pub fn least_role(capability: Capability) -> RepoRole {
232 CAPABILITIES
233 .iter()
234 .find(|row| row.capability == capability)
235 .map_or(RepoRole::Admin, |row| row.role)
236}
237
238/// Whether `role` has `capability`, going by the table alone.
239pub fn allows(role: RepoRole, capability: Capability) -> bool {
240 role >= least_role(capability)
241}
242
243/// A person's role on one repository, given to them directly. Attached by
244/// identity to every user it resolves ([`User::grants`]).
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct RepoGrant {
247 pub repo_id: String,
248 /// The repository's workspace, by slug, as it is now.
249 pub workspace: String,
250 pub role: RepoRole,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar251 /// The team it comes through, by slug, when it is a team's grant.
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub team: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look254}
255
256/// What [`permission`] needs to know about a repository.
257#[derive(Clone, Copy, Debug)]
258pub struct RepoRef<'a> {
259 pub id: &'a str,
260 /// Its workspace's slug.
261 pub namespace: &'a str,
262 pub private: bool,
263}
264
265impl<'a> From<&'a Repo> for RepoRef<'a> {
266 fn from(repo: &'a Repo) -> Self {
267 RepoRef {
268 id: &repo.id,
269 namespace: &repo.namespace,
270 private: repo.is_private,
271 }
272 }
273}
274
275/// What a membership gives on each of the workspace's repositories.
276fn membership_role(user: &User, membership: &Membership) -> Option<RepoRole> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily277 // A workspace's own token, and g1t acting in the workspace, do what an
278 // owner can on its repositories.
279 if matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look280 return Some(RepoRole::Admin);
281 }
282 match membership.role {
283 Role::Owner => Some(RepoRole::Admin),
284 Role::Member => membership.base_permission.unwrap_or_default().role(),
285 }
286}
287
288/// `user`'s role on the repository, not counting that it may be public.
289pub fn granted(user: &User, repo: RepoRef<'_>) -> Option<RepoRole> {
290 let namespace = repo.namespace.to_lowercase();
291 let from_membership = user
292 .workspaces
293 .iter()
294 .find(|membership| membership.slug.eq_ignore_ascii_case(&namespace))
295 .and_then(|membership| membership_role(user, membership));
296 let direct = user
297 .grants
298 .iter()
299 .filter(|grant| grant.repo_id == repo.id)
300 .map(|grant| grant.role)
301 .max();
302 from_membership.max(direct)
303}
304
305/// The viewer's effective role on a repository: `None` means they may not
306/// see it at all (a private repository then looks missing).
307pub fn permission<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>) -> Option<RepoRole> {
308 let repo = repo.into();
309 let role = viewer.and_then(|user| granted(user, repo));
310 if repo.private {
311 role
312 } else {
313 role.max(Some(RepoRole::Read))
314 }
315}
316
317/// Whether the viewer may do `capability` in the repository. Owner-only
318/// capabilities ([`OWNER_ONLY`]) also need the viewer to own its workspace.
319pub fn can<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>, capability: Capability) -> bool {
320 let repo = repo.into();
321 let Some(role) = permission(viewer, repo) else {
322 return false;
323 };
324 if !allows(role, capability) {
325 return false;
326 }
327 if OWNER_ONLY.contains(&capability) {
328 return viewer.is_some_and(|user| user.role_in(&repo.namespace.to_lowercase()) == Some(Role::Owner));
329 }
330 true
331}
332
333/// What a refusal answers: a repository the viewer cannot read is not
334/// found (so private ones cannot be told from missing ones); one they can
335/// read but not act in is forbidden.
336#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337pub enum Denied {
338 NotFound,
339 Forbidden,
340}
341
342/// `Ok` when the viewer may do `capability`; otherwise whether to answer
343/// not found or forbidden.
344pub fn check<'a>(
345 viewer: Option<&User>,
346 repo: impl Into<RepoRef<'a>>,
347 capability: Capability,
348) -> Result<(), Denied> {
349 let repo = repo.into();
350 if permission(viewer, repo).is_none() {
351 return Err(Denied::NotFound);
352 }
353 if can(viewer, repo, capability) {
354 Ok(())
355 } else {
356 Err(Denied::Forbidden)
357 }
358}
359
360/// The sentence a refusal of `capability` gives.
361pub fn needs(capability: Capability, repo: &str) -> String {
362 let role = least_role(capability);
363 if OWNER_ONLY.contains(&capability) {
364 return format!("Only an owner of the workspace can do that to {repo}.");
365 }
366 format!(
367 "You need the {} role or higher on {repo} to do that.",
368 role.label()
369 )
370}
371
372/// Whether the user has any way into the workspace `namespace`: a member,
373/// or someone with a role on one of its repositories.
374pub fn has_access_in(user: &User, namespace: &str) -> bool {
375 let namespace = namespace.to_lowercase();
376 user.is_member(&namespace) || user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
377}
378
379/// Whether the user is an outside collaborator of `namespace`: they have
380/// roles on some of its repositories without belonging to it.
381pub fn is_outside_collaborator(user: &User, namespace: &str) -> bool {
382 let namespace = namespace.to_lowercase();
383 !user.is_member(&namespace) && user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
384}
385
386// --- Who has access ---------------------------------------------------------
387
388/// How a person has their role on a repository.
389#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case")]
391pub enum AccessSource {
392 /// An owner of the workspace: Admin on everything in it.
393 Owner,
394 /// A member, through the workspace's base permission.
395 Base,
396 /// Given a role on this repository directly.
397 Direct,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar398 /// In a team given a role on this repository, or in a child of one.
399 Team,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look400}
401
402/// One person with access to a repository.
403#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
404pub struct Collaborator {
405 pub username: String,
406 /// Their display name, if they set one.
407 pub name: Option<String>,
408 pub avatar: Option<String>,
409 /// Their effective role: the highest of what they have.
410 pub role: RepoRole,
411 /// Where the effective role comes from.
412 pub source: AccessSource,
413 /// Their direct grant on this repository, if any (even when the base
414 /// permission or ownership gives more).
415 pub direct: Option<RepoRole>,
416 /// `owner`, `member`, or null for an outside collaborator.
417 pub workspace_role: Option<Role>,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar418 /// The highest role a team gives them here, and that team's slug.
419 #[serde(default)]
420 pub team_role: Option<RepoRole>,
421 #[serde(default)]
422 pub team: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look423}
424
425/// Where an invitation to a repository stands.
426#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case")]
428pub enum RepoInvitationStatus {
429 Pending,
430 Accepted,
431 Declined,
432 Revoked,
433 Expired,
434}
435
436/// An invitation to collaborate on one repository.
437#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
438pub struct RepoInvitation {
439 pub id: String,
440 /// `workspace/name`, as it is now.
441 pub repo: String,
442 pub repo_id: String,
443 /// Who is invited, when they have an account.
444 pub invitee: Option<String>,
445 /// The address it was sent to, when they had no account yet. Shown
446 /// only to whoever may manage the repository's access.
447 pub email: Option<String>,
448 pub role: RepoRole,
449 /// Who sent it, by username.
450 pub invited_by: Option<String>,
451 /// The avatar of who sent it: the SHA-256 of its bytes, served at
452 /// `/avatars/<avatar>`. None means the generated letter avatar.
453 #[serde(default)]
454 pub inviter_avatar: Option<String>,
455 pub status: RepoInvitationStatus,
456 /// RFC 3339.
457 pub created_at: String,
458 /// RFC 3339.
459 pub expires_at: String,
460}
461
462/// Who has access to a repository, as its Access settings show it.
463#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
464pub struct RepoAccess {
465 pub repo: String,
466 pub base_permission: BasePermission,
467 /// Everyone with access other than through the repository being
468 /// public: owners, members with a base role, and direct grants.
469 pub people: Vec<Collaborator>,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar470 /// The workspace's teams given a role on it.
471 #[serde(default)]
472 pub teams: Vec<crate::teams::RepoTeam>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look473 /// Pending invitations. Empty unless the viewer may manage access.
474 pub invitations: Vec<RepoInvitation>,
475 /// The viewer's own role, and whether they may change who has access.
476 pub viewer_role: Option<RepoRole>,
477 pub can_manage: bool,
478}
479
480/// What adding someone did.
481#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(tag = "result", rename_all = "snake_case")]
483pub enum Added {
484 /// A member of the workspace: given the role at once.
485 Granted { collaborator: Collaborator },
486 /// Anyone else: sent an invitation to accept.
487 Invited { invitation: RepoInvitation },
488}
489
490/// A person's permission on a repository, as
491/// `GET /repos/{owner}/{name}/collaborators/{username}/permission` answers.
492#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
493pub struct PermissionInfo {
494 pub username: String,
495 /// Their role, or null when they have none (on a public repository
496 /// everyone reads it, which this does not count).
497 pub role: Option<RepoRole>,
498 pub source: Option<AccessSource>,
499 /// What the role lets them do, from the permission table.
500 pub capabilities: Vec<Capability>,
501}
502
503/// The capabilities `role` has, in the table's order.
504pub fn capabilities_of(role: Option<RepoRole>) -> Vec<Capability> {
505 CAPABILITIES
506 .iter()
507 .filter(|row| role.is_some_and(|role| role >= row.role))
508 .map(|row| row.capability)
509 .collect()
510}
511
512/// An outside collaborator of a workspace, and what they can reach.
513#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
514pub struct OutsideCollaborator {
515 pub username: String,
516 pub name: Option<String>,
517 pub avatar: Option<String>,
518 pub repos: Vec<CollaboratorRepo>,
519}
520
521#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
522pub struct CollaboratorRepo {
523 /// `workspace/name`.
524 pub repo: String,
525 pub role: RepoRole,
526}
527
528// --- Identity methods ---------------------------------------------------------
529//
530// Served by identity at `POST /rpc/<method>`. Each takes the repository's
531// path and the person asking; identity asks the repos service for the
532// repository as that person sees it, so a repository they cannot read is
533// not found, and one they can read without managing its access is
534// forbidden. Agents' tokens can never change access.
535
536/// `repo_access`: who has access to a repository. Needs Write (as the
537/// list of collaborators does); invitations need Admin.
538/// Returns `Outcome<RepoAccess>`.
539#[derive(Debug, Serialize, Deserialize)]
540pub struct RepoAccessArgs {
541 pub viewer: crate::Viewer,
542 pub path: RepoPath,
543}
544
545/// `add_collaborator`: gives `invitee` (a username or an email address)
546/// `role` on a repository. A member of its workspace gets it at once; a
547/// person with an account is sent an invitation to accept; an address
548/// without one is sent an invite code that makes their account and
549/// accepts. Admin only. Returns `Outcome<Added>`.
550#[derive(Debug, Serialize, Deserialize)]
551pub struct AddCollaboratorArgs {
552 pub actor: User,
553 pub path: RepoPath,
554 pub invitee: String,
555 pub role: RepoRole,
556 #[serde(default)]
557 pub surface: Option<crate::audit::Surface>,
558}
559
560/// `set_collaborator_role`: changes a direct grant, or a pending
561/// invitation's role. Admin only. Returns `Outcome<Collaborator>`.
562#[derive(Debug, Serialize, Deserialize)]
563pub struct SetCollaboratorRoleArgs {
564 pub actor: User,
565 pub path: RepoPath,
566 pub username: String,
567 pub role: RepoRole,
568 #[serde(default)]
569 pub surface: Option<crate::audit::Surface>,
570}
571
572/// `remove_collaborator`: takes away a direct grant. Admin only; anyone
573/// may remove themselves. Owners and the base permission are not changed
574/// here. Returns `Outcome<bool>`.
575#[derive(Debug, Serialize, Deserialize)]
576pub struct RemoveCollaboratorArgs {
577 pub actor: User,
578 pub path: RepoPath,
579 pub username: String,
580 #[serde(default)]
581 pub surface: Option<crate::audit::Surface>,
582}
583
584/// `collaborator_permission`: `username`'s role on a repository. Needs
585/// Write, or to be asking about yourself. Returns `Outcome<PermissionInfo>`.
586#[derive(Debug, Serialize, Deserialize)]
587pub struct CollaboratorPermissionArgs {
588 pub viewer: crate::Viewer,
589 pub path: RepoPath,
590 pub username: String,
591}
592
593/// `my_repo_invitations`: the invitations waiting for `user` to answer.
594/// Returns `Vec<RepoInvitation>`.
595#[derive(Debug, Serialize, Deserialize)]
596pub struct MyRepoInvitationsArgs {
597 pub user: User,
598}
599
600/// `respond_repo_invitation`: accept or decline an invitation sent to you.
601/// Accepting is checked against the workspace's policy. Returns
602/// `Outcome<RepoInvitation>`.
603#[derive(Debug, Serialize, Deserialize)]
604pub struct RespondRepoInvitationArgs {
605 pub user: User,
606 pub id: String,
607 pub accept: bool,
608}
609
610/// `revoke_repo_invitation`: withdraw a pending invitation. Admin only.
611/// Returns `Outcome<RepoInvitation>`.
612#[derive(Debug, Serialize, Deserialize)]
613pub struct RevokeRepoInvitationArgs {
614 pub actor: User,
615 pub path: RepoPath,
616 pub id: String,
617 #[serde(default)]
618 pub surface: Option<crate::audit::Surface>,
619}
620
621/// `set_base_permission`: what every member gets on every repository.
622/// Owners only, as a person. Returns `Outcome<BasePermission>`.
623#[derive(Debug, Serialize, Deserialize)]
624pub struct SetBasePermissionArgs {
625 pub actor: User,
626 pub slug: String,
627 pub base_permission: BasePermission,
628 #[serde(default)]
629 pub surface: Option<crate::audit::Surface>,
630}
631
632/// `outside_collaborators`: the people with roles on a workspace's
633/// repositories who are not its members. Owners only. Returns
634/// `Outcome<Vec<OutsideCollaborator>>`.
635#[derive(Debug, Serialize, Deserialize)]
636pub struct OutsideCollaboratorsArgs {
637 pub viewer: crate::Viewer,
638 pub slug: String,
639}
640
641/// `forget_repo_access`: a repository was purged; its grants and
642/// invitations go with it. For the repos service. Returns `bool`.
643#[derive(Debug, Serialize, Deserialize)]
644pub struct ForgetRepoAccessArgs {
645 pub repo_id: String,
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 fn user(memberships: &[(&str, Role, Option<BasePermission>)], grants: &[(&str, &str, RepoRole)]) -> User {
653 User {
654 id: "usr_1".into(),
655 username: "ana".into(),
656 verified: true,
657 workspaces: memberships
658 .iter()
659 .map(|(slug, role, base)| Membership {
660 slug: (*slug).into(),
661 role: *role,
662 name: None,
663 avatar: None,
664 base_permission: *base,
665 })
666 .collect(),
667 grants: grants
668 .iter()
669 .map(|(id, workspace, role)| RepoGrant {
670 repo_id: (*id).into(),
671 workspace: (*workspace).into(),
672 role: *role,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar673 team: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look674 })
675 .collect(),
676 ..User::default()
677 }
678 }
679
680 fn repo(id: &'static str, namespace: &'static str, private: bool) -> RepoRef<'static> {
681 RepoRef { id, namespace, private }
682 }
683
684 /// Every role against every capability: the whole table, spelled out.
685 #[test]
686 fn every_role_has_exactly_the_capabilities_of_the_table() {
687 use Capability::*;
688 let expected: [(RepoRole, &[Capability]); 5] = [
689 (RepoRole::Read, &[Read, Participate]),
690 (RepoRole::Triage, &[Read, Participate, Triage]),
691 (RepoRole::Write, &[Read, Participate, Triage, Push, Merge, Run]),
692 (
693 RepoRole::Maintain,
694 &[Read, Participate, Triage, Push, Merge, Run, ManageSettings, ManageProtection],
695 ),
696 (
697 RepoRole::Admin,
698 &[
699 Read, Participate, Triage, Push, Merge, Run, ManageSettings, ManageProtection,
700 ManageIntegrations, ManageAccess, Administer, Delete,
701 ],
702 ),
703 ];
704 for (role, has) in expected {
705 for row in CAPABILITIES {
706 assert_eq!(
707 allows(role, row.capability),
708 has.contains(&row.capability),
709 "{} and {}",
710 role.as_str(),
711 row.capability.as_str()
712 );
713 }
714 assert_eq!(capabilities_of(Some(role)), has.to_vec());
715 }
716 assert!(capabilities_of(None).is_empty());
717 }
718
719 #[test]
720 fn read_cannot_spend_compute() {
721 assert!(!allows(RepoRole::Read, Capability::Run));
722 assert!(!allows(RepoRole::Triage, Capability::Run));
723 assert!(allows(RepoRole::Write, Capability::Run));
724 }
725
726 #[test]
727 fn owners_have_admin_on_everything_in_their_workspace() {
728 let owner = user(&[("acme", Role::Owner, Some(BasePermission::None))], &[]);
729 assert_eq!(permission(Some(&owner), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
730 assert!(can(Some(&owner), repo("rep_1", "acme", true), Capability::Delete));
731 }
732
733 #[test]
734 fn members_get_the_base_permission_and_write_when_unset() {
735 let unset = user(&[("acme", Role::Member, None)], &[]);
736 assert_eq!(permission(Some(&unset), repo("rep_1", "acme", true)), Some(RepoRole::Write));
737 let read = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
738 assert_eq!(permission(Some(&read), repo("rep_1", "acme", true)), Some(RepoRole::Read));
739 let none = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
740 assert_eq!(permission(Some(&none), repo("rep_1", "acme", true)), None);
741 assert_eq!(permission(Some(&none), repo("rep_1", "acme", false)), Some(RepoRole::Read));
742 }
743
744 /// The default keeps what members could do before roles: read, push,
745 /// merge, run agents.
746 #[test]
747 fn the_default_base_permission_keeps_members_working() {
748 assert_eq!(BasePermission::default(), BasePermission::Write);
749 let member = user(&[("acme", Role::Member, None)], &[]);
750 for capability in [Capability::Read, Capability::Participate, Capability::Push, Capability::Merge, Capability::Run] {
751 assert!(can(Some(&member), repo("rep_1", "acme", true), capability));
752 }
753 // Transfer and delete were owners' only, and still are.
754 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::Delete));
755 }
756
757 #[test]
758 fn effective_permission_is_the_highest_source() {
759 let member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Maintain)]);
760 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
761 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
762 // A grant lower than the base changes nothing.
763 let member = user(&[("acme", Role::Member, Some(BasePermission::Admin))], &[("rep_1", "acme", RepoRole::Read)]);
764 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
765 }
766
767 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar768 fn a_teams_grant_counts_like_any_other_and_the_highest_wins() {
769 let mut member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
770 member.grants.push(RepoGrant {
771 repo_id: "rep_1".into(),
772 workspace: "acme".into(),
773 role: RepoRole::Maintain,
774 team: Some("backend".into()),
775 });
776 member.grants.push(RepoGrant {
777 repo_id: "rep_1".into(),
778 workspace: "acme".into(),
779 role: RepoRole::Triage,
780 team: None,
781 });
782 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
783 assert!(can(Some(&member), repo("rep_1", "acme", true), Capability::ManageProtection));
784 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::ManageAccess));
785 // Elsewhere, only the base.
786 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
787 // Serialized without `team` when it is a person's own.
788 let own = serde_json::to_value(&member.grants[1]).unwrap();
789 assert!(own.get("team").is_none());
790 assert_eq!(serde_json::to_value(&member.grants[0]).unwrap()["team"], "backend");
791 }
792
793 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look794 fn outside_collaborators_reach_only_their_repositories() {
795 let outsider = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
796 assert_eq!(permission(Some(&outsider), repo("rep_1", "acme", true)), Some(RepoRole::Triage));
797 assert_eq!(permission(Some(&outsider), repo("rep_2", "acme", true)), None);
798 assert_eq!(check(Some(&outsider), repo("rep_2", "acme", true), Capability::Read), Err(Denied::NotFound));
799 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Push), Err(Denied::Forbidden));
800 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Triage), Ok(()));
801 assert!(is_outside_collaborator(&outsider, "acme"));
802 assert!(has_access_in(&outsider, "acme"));
803 assert!(!has_access_in(&outsider, "globex"));
804 }
805
806 #[test]
807 fn a_direct_admin_cannot_transfer_or_delete() {
808 let admin = user(&[], &[("rep_1", "acme", RepoRole::Admin)]);
809 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::Administer));
810 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::ManageAccess));
811 assert!(!can(Some(&admin), repo("rep_1", "acme", true), Capability::Delete));
812 }
813
814 #[test]
815 fn anyone_reads_a_public_repository_and_nothing_more() {
816 assert_eq!(permission(None, repo("rep_1", "acme", false)), Some(RepoRole::Read));
817 assert_eq!(permission(None, repo("rep_1", "acme", true)), None);
818 assert!(can(None, repo("rep_1", "acme", false), Capability::Read));
819 assert!(!can(None, repo("rep_1", "acme", false), Capability::Triage));
820 let stranger = user(&[("globex", Role::Owner, None)], &[]);
821 assert_eq!(check(Some(&stranger), repo("rep_1", "acme", false), Capability::Push), Err(Denied::Forbidden));
822 }
823
824 #[test]
825 fn a_workspace_token_has_admin_in_its_workspace_only() {
826 let token = User {
827 id: "wsp_1".into(),
828 username: "acme".into(),
829 kind: PrincipalKind::Workspace,
830 workspaces: vec![Membership::member("acme")],
831 ..User::default()
832 };
833 assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
834 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
835 }
836
837 #[test]
838 fn roles_and_base_permissions_read_and_write_as_words() {
839 for role in RepoRole::ALL {
840 assert_eq!(RepoRole::parse(role.as_str()), Some(role));
841 assert_eq!(serde_json::to_value(role).unwrap(), role.as_str());
842 }
843 for base in BasePermission::ALL {
844 assert_eq!(BasePermission::parse(base.as_str()), Some(base));
845 assert_eq!(serde_json::to_value(base).unwrap(), base.as_str());
846 }
847 for row in CAPABILITIES {
848 assert_eq!(serde_json::to_value(row.capability).unwrap(), row.capability.as_str());
849 }
850 assert!(RepoRole::Read < RepoRole::Triage && RepoRole::Maintain < RepoRole::Admin);
851 }
852
853 /// `packages/contracts/src/access.ts` lists the same table, in the
854 /// same order, with the same least roles.
855 #[test]
856 fn the_typescript_mirror_has_the_same_table() {
857 let ts = include_str!("../../../packages/contracts/src/access.ts");
858 let table = ts
859 .split_once("export const CAPABILITIES = [")
860 .and_then(|(_, rest)| rest.split_once("] as const"))
861 .map(|(table, _)| table)
862 .expect("CAPABILITIES in access.ts");
863 let rows: Vec<(String, String)> = table
864 .lines()
865 .filter_map(|line| {
866 let capability = line.split_once("capability: \"")?.1.split_once('"')?.0;
867 let role = line.split_once("role: \"")?.1.split_once('"')?.0;
868 Some((capability.to_owned(), role.to_owned()))
869 })
870 .collect();
871 let expected: Vec<(String, String)> = CAPABILITIES
872 .iter()
873 .map(|row| (row.capability.as_str().to_owned(), row.role.as_str().to_owned()))
874 .collect();
875 assert_eq!(rows, expected);
876 let owner_only = ts
877 .split_once("export const OWNER_ONLY = [")
878 .and_then(|(_, rest)| rest.split_once(']'))
879 .map(|(list, _)| list)
880 .expect("OWNER_ONLY in access.ts");
881 let mirrored: Vec<&str> = owner_only
882 .split(',')
883 .map(|item| item.trim().trim_matches('"'))
884 .filter(|item| !item.is_empty())
885 .collect();
886 let expected: Vec<&str> = OWNER_ONLY.iter().map(|capability| capability.as_str()).collect();
887 assert_eq!(mirrored, expected);
888 }
889}