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

845 lines33,070 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//!
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 if user.kind == PrincipalKind::Workspace {
273 return Some(RepoRole::Admin);
274 }
275 match membership.role {
276 Role::Owner => Some(RepoRole::Admin),
277 Role::Member => membership.base_permission.unwrap_or_default().role(),
278 }
279}
280
281/// `user`'s role on the repository, not counting that it may be public.
282pub fn granted(user: &User, repo: RepoRef<'_>) -> Option<RepoRole> {
283 let namespace = repo.namespace.to_lowercase();
284 let from_membership = user
285 .workspaces
286 .iter()
287 .find(|membership| membership.slug.eq_ignore_ascii_case(&namespace))
288 .and_then(|membership| membership_role(user, membership));
289 let direct = user
290 .grants
291 .iter()
292 .filter(|grant| grant.repo_id == repo.id)
293 .map(|grant| grant.role)
294 .max();
295 from_membership.max(direct)
296}
297
298/// The viewer's effective role on a repository: `None` means they may not
299/// see it at all (a private repository then looks missing).
300pub fn permission<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>) -> Option<RepoRole> {
301 let repo = repo.into();
302 let role = viewer.and_then(|user| granted(user, repo));
303 if repo.private {
304 role
305 } else {
306 role.max(Some(RepoRole::Read))
307 }
308}
309
310/// Whether the viewer may do `capability` in the repository. Owner-only
311/// capabilities ([`OWNER_ONLY`]) also need the viewer to own its workspace.
312pub fn can<'a>(viewer: Option<&User>, repo: impl Into<RepoRef<'a>>, capability: Capability) -> bool {
313 let repo = repo.into();
314 let Some(role) = permission(viewer, repo) else {
315 return false;
316 };
317 if !allows(role, capability) {
318 return false;
319 }
320 if OWNER_ONLY.contains(&capability) {
321 return viewer.is_some_and(|user| user.role_in(&repo.namespace.to_lowercase()) == Some(Role::Owner));
322 }
323 true
324}
325
326/// What a refusal answers: a repository the viewer cannot read is not
327/// found (so private ones cannot be told from missing ones); one they can
328/// read but not act in is forbidden.
329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330pub enum Denied {
331 NotFound,
332 Forbidden,
333}
334
335/// `Ok` when the viewer may do `capability`; otherwise whether to answer
336/// not found or forbidden.
337pub fn check<'a>(
338 viewer: Option<&User>,
339 repo: impl Into<RepoRef<'a>>,
340 capability: Capability,
341) -> Result<(), Denied> {
342 let repo = repo.into();
343 if permission(viewer, repo).is_none() {
344 return Err(Denied::NotFound);
345 }
346 if can(viewer, repo, capability) {
347 Ok(())
348 } else {
349 Err(Denied::Forbidden)
350 }
351}
352
353/// The sentence a refusal of `capability` gives.
354pub fn needs(capability: Capability, repo: &str) -> String {
355 let role = least_role(capability);
356 if OWNER_ONLY.contains(&capability) {
357 return format!("Only an owner of the workspace can do that to {repo}.");
358 }
359 format!(
360 "You need the {} role or higher on {repo} to do that.",
361 role.label()
362 )
363}
364
365/// Whether the user has any way into the workspace `namespace`: a member,
366/// or someone with a role on one of its repositories.
367pub fn has_access_in(user: &User, namespace: &str) -> bool {
368 let namespace = namespace.to_lowercase();
369 user.is_member(&namespace) || user.grants.iter().any(|grant| grant.workspace.eq_ignore_ascii_case(&namespace))
370}
371
372/// Whether the user is an outside collaborator of `namespace`: they have
373/// roles on some of its repositories without belonging to it.
374pub fn is_outside_collaborator(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// --- Who has access ---------------------------------------------------------
380
381/// How a person has their role on a repository.
382#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum AccessSource {
385 /// An owner of the workspace: Admin on everything in it.
386 Owner,
387 /// A member, through the workspace's base permission.
388 Base,
389 /// Given a role on this repository directly.
390 Direct,
391}
392
393/// One person with access to a repository.
394#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
395pub struct Collaborator {
396 pub username: String,
397 /// Their display name, if they set one.
398 pub name: Option<String>,
399 pub avatar: Option<String>,
400 /// Their effective role: the highest of what they have.
401 pub role: RepoRole,
402 /// Where the effective role comes from.
403 pub source: AccessSource,
404 /// Their direct grant on this repository, if any (even when the base
405 /// permission or ownership gives more).
406 pub direct: Option<RepoRole>,
407 /// `owner`, `member`, or null for an outside collaborator.
408 pub workspace_role: Option<Role>,
409}
410
411/// Where an invitation to a repository stands.
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case")]
414pub enum RepoInvitationStatus {
415 Pending,
416 Accepted,
417 Declined,
418 Revoked,
419 Expired,
420}
421
422/// An invitation to collaborate on one repository.
423#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
424pub struct RepoInvitation {
425 pub id: String,
426 /// `workspace/name`, as it is now.
427 pub repo: String,
428 pub repo_id: String,
429 /// Who is invited, when they have an account.
430 pub invitee: Option<String>,
431 /// The address it was sent to, when they had no account yet. Shown
432 /// only to whoever may manage the repository's access.
433 pub email: Option<String>,
434 pub role: RepoRole,
435 /// Who sent it, by username.
436 pub invited_by: Option<String>,
437 /// The avatar of who sent it: the SHA-256 of its bytes, served at
438 /// `/avatars/<avatar>`. None means the generated letter avatar.
439 #[serde(default)]
440 pub inviter_avatar: Option<String>,
441 pub status: RepoInvitationStatus,
442 /// RFC 3339.
443 pub created_at: String,
444 /// RFC 3339.
445 pub expires_at: String,
446}
447
448/// Who has access to a repository, as its Access settings show it.
449#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
450pub struct RepoAccess {
451 pub repo: String,
452 pub base_permission: BasePermission,
453 /// Everyone with access other than through the repository being
454 /// public: owners, members with a base role, and direct grants.
455 pub people: Vec<Collaborator>,
456 /// Pending invitations. Empty unless the viewer may manage access.
457 pub invitations: Vec<RepoInvitation>,
458 /// The viewer's own role, and whether they may change who has access.
459 pub viewer_role: Option<RepoRole>,
460 pub can_manage: bool,
461}
462
463/// What adding someone did.
464#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(tag = "result", rename_all = "snake_case")]
466pub enum Added {
467 /// A member of the workspace: given the role at once.
468 Granted { collaborator: Collaborator },
469 /// Anyone else: sent an invitation to accept.
470 Invited { invitation: RepoInvitation },
471}
472
473/// A person's permission on a repository, as
474/// `GET /repos/{owner}/{name}/collaborators/{username}/permission` answers.
475#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
476pub struct PermissionInfo {
477 pub username: String,
478 /// Their role, or null when they have none (on a public repository
479 /// everyone reads it, which this does not count).
480 pub role: Option<RepoRole>,
481 pub source: Option<AccessSource>,
482 /// What the role lets them do, from the permission table.
483 pub capabilities: Vec<Capability>,
484}
485
486/// The capabilities `role` has, in the table's order.
487pub fn capabilities_of(role: Option<RepoRole>) -> Vec<Capability> {
488 CAPABILITIES
489 .iter()
490 .filter(|row| role.is_some_and(|role| role >= row.role))
491 .map(|row| row.capability)
492 .collect()
493}
494
495/// An outside collaborator of a workspace, and what they can reach.
496#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
497pub struct OutsideCollaborator {
498 pub username: String,
499 pub name: Option<String>,
500 pub avatar: Option<String>,
501 pub repos: Vec<CollaboratorRepo>,
502}
503
504#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
505pub struct CollaboratorRepo {
506 /// `workspace/name`.
507 pub repo: String,
508 pub role: RepoRole,
509}
510
511// --- Identity methods ---------------------------------------------------------
512//
513// Served by identity at `POST /rpc/<method>`. Each takes the repository's
514// path and the person asking; identity asks the repos service for the
515// repository as that person sees it, so a repository they cannot read is
516// not found, and one they can read without managing its access is
517// forbidden. Agents' tokens can never change access.
518
519/// `repo_access`: who has access to a repository. Needs Write (as the
520/// list of collaborators does); invitations need Admin.
521/// Returns `Outcome<RepoAccess>`.
522#[derive(Debug, Serialize, Deserialize)]
523pub struct RepoAccessArgs {
524 pub viewer: crate::Viewer,
525 pub path: RepoPath,
526}
527
528/// `add_collaborator`: gives `invitee` (a username or an email address)
529/// `role` on a repository. A member of its workspace gets it at once; a
530/// person with an account is sent an invitation to accept; an address
531/// without one is sent an invite code that makes their account and
532/// accepts. Admin only. Returns `Outcome<Added>`.
533#[derive(Debug, Serialize, Deserialize)]
534pub struct AddCollaboratorArgs {
535 pub actor: User,
536 pub path: RepoPath,
537 pub invitee: String,
538 pub role: RepoRole,
539 #[serde(default)]
540 pub surface: Option<crate::audit::Surface>,
541}
542
543/// `set_collaborator_role`: changes a direct grant, or a pending
544/// invitation's role. Admin only. Returns `Outcome<Collaborator>`.
545#[derive(Debug, Serialize, Deserialize)]
546pub struct SetCollaboratorRoleArgs {
547 pub actor: User,
548 pub path: RepoPath,
549 pub username: String,
550 pub role: RepoRole,
551 #[serde(default)]
552 pub surface: Option<crate::audit::Surface>,
553}
554
555/// `remove_collaborator`: takes away a direct grant. Admin only; anyone
556/// may remove themselves. Owners and the base permission are not changed
557/// here. Returns `Outcome<bool>`.
558#[derive(Debug, Serialize, Deserialize)]
559pub struct RemoveCollaboratorArgs {
560 pub actor: User,
561 pub path: RepoPath,
562 pub username: String,
563 #[serde(default)]
564 pub surface: Option<crate::audit::Surface>,
565}
566
567/// `collaborator_permission`: `username`'s role on a repository. Needs
568/// Write, or to be asking about yourself. Returns `Outcome<PermissionInfo>`.
569#[derive(Debug, Serialize, Deserialize)]
570pub struct CollaboratorPermissionArgs {
571 pub viewer: crate::Viewer,
572 pub path: RepoPath,
573 pub username: String,
574}
575
576/// `my_repo_invitations`: the invitations waiting for `user` to answer.
577/// Returns `Vec<RepoInvitation>`.
578#[derive(Debug, Serialize, Deserialize)]
579pub struct MyRepoInvitationsArgs {
580 pub user: User,
581}
582
583/// `respond_repo_invitation`: accept or decline an invitation sent to you.
584/// Accepting is checked against the workspace's policy. Returns
585/// `Outcome<RepoInvitation>`.
586#[derive(Debug, Serialize, Deserialize)]
587pub struct RespondRepoInvitationArgs {
588 pub user: User,
589 pub id: String,
590 pub accept: bool,
591}
592
593/// `revoke_repo_invitation`: withdraw a pending invitation. Admin only.
594/// Returns `Outcome<RepoInvitation>`.
595#[derive(Debug, Serialize, Deserialize)]
596pub struct RevokeRepoInvitationArgs {
597 pub actor: User,
598 pub path: RepoPath,
599 pub id: String,
600 #[serde(default)]
601 pub surface: Option<crate::audit::Surface>,
602}
603
604/// `set_base_permission`: what every member gets on every repository.
605/// Owners only, as a person. Returns `Outcome<BasePermission>`.
606#[derive(Debug, Serialize, Deserialize)]
607pub struct SetBasePermissionArgs {
608 pub actor: User,
609 pub slug: String,
610 pub base_permission: BasePermission,
611 #[serde(default)]
612 pub surface: Option<crate::audit::Surface>,
613}
614
615/// `outside_collaborators`: the people with roles on a workspace's
616/// repositories who are not its members. Owners only. Returns
617/// `Outcome<Vec<OutsideCollaborator>>`.
618#[derive(Debug, Serialize, Deserialize)]
619pub struct OutsideCollaboratorsArgs {
620 pub viewer: crate::Viewer,
621 pub slug: String,
622}
623
624/// `forget_repo_access`: a repository was purged; its grants and
625/// invitations go with it. For the repos service. Returns `bool`.
626#[derive(Debug, Serialize, Deserialize)]
627pub struct ForgetRepoAccessArgs {
628 pub repo_id: String,
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 fn user(memberships: &[(&str, Role, Option<BasePermission>)], grants: &[(&str, &str, RepoRole)]) -> User {
636 User {
637 id: "usr_1".into(),
638 username: "ana".into(),
639 verified: true,
640 workspaces: memberships
641 .iter()
642 .map(|(slug, role, base)| Membership {
643 slug: (*slug).into(),
644 role: *role,
645 name: None,
646 avatar: None,
647 base_permission: *base,
648 })
649 .collect(),
650 grants: grants
651 .iter()
652 .map(|(id, workspace, role)| RepoGrant {
653 repo_id: (*id).into(),
654 workspace: (*workspace).into(),
655 role: *role,
656 })
657 .collect(),
658 ..User::default()
659 }
660 }
661
662 fn repo(id: &'static str, namespace: &'static str, private: bool) -> RepoRef<'static> {
663 RepoRef { id, namespace, private }
664 }
665
666 /// Every role against every capability: the whole table, spelled out.
667 #[test]
668 fn every_role_has_exactly_the_capabilities_of_the_table() {
669 use Capability::*;
670 let expected: [(RepoRole, &[Capability]); 5] = [
671 (RepoRole::Read, &[Read, Participate]),
672 (RepoRole::Triage, &[Read, Participate, Triage]),
673 (RepoRole::Write, &[Read, Participate, Triage, Push, Merge, Run]),
674 (
675 RepoRole::Maintain,
676 &[Read, Participate, Triage, Push, Merge, Run, ManageSettings, ManageProtection],
677 ),
678 (
679 RepoRole::Admin,
680 &[
681 Read, Participate, Triage, Push, Merge, Run, ManageSettings, ManageProtection,
682 ManageIntegrations, ManageAccess, Administer, Delete,
683 ],
684 ),
685 ];
686 for (role, has) in expected {
687 for row in CAPABILITIES {
688 assert_eq!(
689 allows(role, row.capability),
690 has.contains(&row.capability),
691 "{} and {}",
692 role.as_str(),
693 row.capability.as_str()
694 );
695 }
696 assert_eq!(capabilities_of(Some(role)), has.to_vec());
697 }
698 assert!(capabilities_of(None).is_empty());
699 }
700
701 #[test]
702 fn read_cannot_spend_compute() {
703 assert!(!allows(RepoRole::Read, Capability::Run));
704 assert!(!allows(RepoRole::Triage, Capability::Run));
705 assert!(allows(RepoRole::Write, Capability::Run));
706 }
707
708 #[test]
709 fn owners_have_admin_on_everything_in_their_workspace() {
710 let owner = user(&[("acme", Role::Owner, Some(BasePermission::None))], &[]);
711 assert_eq!(permission(Some(&owner), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
712 assert!(can(Some(&owner), repo("rep_1", "acme", true), Capability::Delete));
713 }
714
715 #[test]
716 fn members_get_the_base_permission_and_write_when_unset() {
717 let unset = user(&[("acme", Role::Member, None)], &[]);
718 assert_eq!(permission(Some(&unset), repo("rep_1", "acme", true)), Some(RepoRole::Write));
719 let read = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[]);
720 assert_eq!(permission(Some(&read), repo("rep_1", "acme", true)), Some(RepoRole::Read));
721 let none = user(&[("acme", Role::Member, Some(BasePermission::None))], &[]);
722 assert_eq!(permission(Some(&none), repo("rep_1", "acme", true)), None);
723 assert_eq!(permission(Some(&none), repo("rep_1", "acme", false)), Some(RepoRole::Read));
724 }
725
726 /// The default keeps what members could do before roles: read, push,
727 /// merge, run agents.
728 #[test]
729 fn the_default_base_permission_keeps_members_working() {
730 assert_eq!(BasePermission::default(), BasePermission::Write);
731 let member = user(&[("acme", Role::Member, None)], &[]);
732 for capability in [Capability::Read, Capability::Participate, Capability::Push, Capability::Merge, Capability::Run] {
733 assert!(can(Some(&member), repo("rep_1", "acme", true), capability));
734 }
735 // Transfer and delete were owners' only, and still are.
736 assert!(!can(Some(&member), repo("rep_1", "acme", true), Capability::Delete));
737 }
738
739 #[test]
740 fn effective_permission_is_the_highest_source() {
741 let member = user(&[("acme", Role::Member, Some(BasePermission::Read))], &[("rep_1", "acme", RepoRole::Maintain)]);
742 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Maintain));
743 assert_eq!(permission(Some(&member), repo("rep_2", "acme", true)), Some(RepoRole::Read));
744 // A grant lower than the base changes nothing.
745 let member = user(&[("acme", Role::Member, Some(BasePermission::Admin))], &[("rep_1", "acme", RepoRole::Read)]);
746 assert_eq!(permission(Some(&member), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
747 }
748
749 #[test]
750 fn outside_collaborators_reach_only_their_repositories() {
751 let outsider = user(&[], &[("rep_1", "acme", RepoRole::Triage)]);
752 assert_eq!(permission(Some(&outsider), repo("rep_1", "acme", true)), Some(RepoRole::Triage));
753 assert_eq!(permission(Some(&outsider), repo("rep_2", "acme", true)), None);
754 assert_eq!(check(Some(&outsider), repo("rep_2", "acme", true), Capability::Read), Err(Denied::NotFound));
755 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Push), Err(Denied::Forbidden));
756 assert_eq!(check(Some(&outsider), repo("rep_1", "acme", true), Capability::Triage), Ok(()));
757 assert!(is_outside_collaborator(&outsider, "acme"));
758 assert!(has_access_in(&outsider, "acme"));
759 assert!(!has_access_in(&outsider, "globex"));
760 }
761
762 #[test]
763 fn a_direct_admin_cannot_transfer_or_delete() {
764 let admin = user(&[], &[("rep_1", "acme", RepoRole::Admin)]);
765 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::Administer));
766 assert!(can(Some(&admin), repo("rep_1", "acme", true), Capability::ManageAccess));
767 assert!(!can(Some(&admin), repo("rep_1", "acme", true), Capability::Delete));
768 }
769
770 #[test]
771 fn anyone_reads_a_public_repository_and_nothing_more() {
772 assert_eq!(permission(None, repo("rep_1", "acme", false)), Some(RepoRole::Read));
773 assert_eq!(permission(None, repo("rep_1", "acme", true)), None);
774 assert!(can(None, repo("rep_1", "acme", false), Capability::Read));
775 assert!(!can(None, repo("rep_1", "acme", false), Capability::Triage));
776 let stranger = user(&[("globex", Role::Owner, None)], &[]);
777 assert_eq!(check(Some(&stranger), repo("rep_1", "acme", false), Capability::Push), Err(Denied::Forbidden));
778 }
779
780 #[test]
781 fn a_workspace_token_has_admin_in_its_workspace_only() {
782 let token = User {
783 id: "wsp_1".into(),
784 username: "acme".into(),
785 kind: PrincipalKind::Workspace,
786 workspaces: vec![Membership::member("acme")],
787 ..User::default()
788 };
789 assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin));
790 assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None);
791 }
792
793 #[test]
794 fn roles_and_base_permissions_read_and_write_as_words() {
795 for role in RepoRole::ALL {
796 assert_eq!(RepoRole::parse(role.as_str()), Some(role));
797 assert_eq!(serde_json::to_value(role).unwrap(), role.as_str());
798 }
799 for base in BasePermission::ALL {
800 assert_eq!(BasePermission::parse(base.as_str()), Some(base));
801 assert_eq!(serde_json::to_value(base).unwrap(), base.as_str());
802 }
803 for row in CAPABILITIES {
804 assert_eq!(serde_json::to_value(row.capability).unwrap(), row.capability.as_str());
805 }
806 assert!(RepoRole::Read < RepoRole::Triage && RepoRole::Maintain < RepoRole::Admin);
807 }
808
809 /// `packages/contracts/src/access.ts` lists the same table, in the
810 /// same order, with the same least roles.
811 #[test]
812 fn the_typescript_mirror_has_the_same_table() {
813 let ts = include_str!("../../../packages/contracts/src/access.ts");
814 let table = ts
815 .split_once("export const CAPABILITIES = [")
816 .and_then(|(_, rest)| rest.split_once("] as const"))
817 .map(|(table, _)| table)
818 .expect("CAPABILITIES in access.ts");
819 let rows: Vec<(String, String)> = table
820 .lines()
821 .filter_map(|line| {
822 let capability = line.split_once("capability: \"")?.1.split_once('"')?.0;
823 let role = line.split_once("role: \"")?.1.split_once('"')?.0;
824 Some((capability.to_owned(), role.to_owned()))
825 })
826 .collect();
827 let expected: Vec<(String, String)> = CAPABILITIES
828 .iter()
829 .map(|row| (row.capability.as_str().to_owned(), row.role.as_str().to_owned()))
830 .collect();
831 assert_eq!(rows, expected);
832 let owner_only = ts
833 .split_once("export const OWNER_ONLY = [")
834 .and_then(|(_, rest)| rest.split_once(']'))
835 .map(|(list, _)| list)
836 .expect("OWNER_ONLY in access.ts");
837 let mirrored: Vec<&str> = owner_only
838 .split(',')
839 .map(|item| item.trim().trim_matches('"'))
840 .filter(|item| !item.is_empty())
841 .collect();
842 let expected: Vec<&str> = OWNER_ONLY.iter().map(|capability| capability.as_str()).collect();
843 assert_eq!(mirrored, expected);
844 }
845}