flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/access.rs

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