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