Skip to content
755 linesCodeBlameRaw
1//! Who may pull, push, delete and administer a package.
2//!
3//! A package's role for someone is the most of:
4//!
5//! - **Its repository's**, for a linked package that inherits access (the
6//! default): Read and Triage pull, Write and Maintain publish, Admin
7//! administers.
8//! - **Its workspace's**, for an unlinked one: members by the base
9//! permission, at most Write.
10//! - **Ownership**: the workspace's owners administer every package.
11//! - **Its own grants**: people and teams given Read, Write or Admin on
12//! the package itself (its Manage access settings).
13//!
14//! Anyone pulls a public package. A token is limited further by its scopes
15//! (`packages:read`, `packages:write`, `packages:delete`); a token made
16//! for one workspace only reaches packages inside it and its repository
17//! selection; a workspace's own token is a member with Write unless an
18//! owner gave it Admin. A workflow job's token reaches a package only from
19//! the repository it is linked to (Write) or from a repository listed under
20//! the package's Manage Actions access, with that role. A sandbox run's
21//! token (checks, the merge queue, a deployment) may push only where its
22//! run may push code. A deploy key never
23//! reaches packages.
24
25use g1t_contracts::access::{self, RepoRef, RepoRole};
26use g1t_contracts::credentials::{self, Decision};
27use g1t_contracts::packages::{GranteeKind, PackagePermissions, PackageRole};
28use g1t_contracts::repos::RepoPath;
29use g1t_contracts::scopes::{self, Level, TokenAccess};
30use g1t_contracts::{PrincipalKind, Role, User};
31use serde::{Deserialize, Serialize};
32
33/// What is done to a package. Registry tokens name the first three;
34/// `Admin` is changing its settings and access, `Settings` reading them
35/// (and its deleted versions).
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Action {
39 Pull,
40 Push,
41 Delete,
42 Admin,
43 Settings,
44}
45
46impl Action {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Action::Pull => "pull",
50 Action::Push => "push",
51 Action::Delete => "delete",
52 Action::Admin => "administer",
53 Action::Settings => "see the settings of",
54 }
55 }
56
57 /// The token scope level it needs: reading settings `packages:read`,
58 /// changing them `packages:write` (both with the Admin role), deleting
59 /// `packages:delete`.
60 fn level(self) -> Level {
61 match self {
62 Action::Pull | Action::Settings => Level::Read,
63 Action::Push | Action::Admin => Level::Write,
64 Action::Delete => Level::Delete,
65 }
66 }
67
68 fn needs(self) -> PackageRole {
69 match self {
70 Action::Pull => PackageRole::Read,
71 Action::Push => PackageRole::Write,
72 Action::Delete | Action::Admin | Action::Settings => PackageRole::Admin,
73 }
74 }
75
76 /// Whether only the package's admins may do it.
77 fn administers(self) -> bool {
78 matches!(self, Action::Delete | Action::Admin | Action::Settings)
79 }
80}
81
82/// The repository a package is linked to, or would be on its first push.
83#[derive(Clone, Copy, Debug)]
84pub struct LinkedTo<'a> {
85 pub id: &'a str,
86 pub name: &'a str,
87 pub private: bool,
88}
89
90/// A role given on the package itself.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct Grant {
93 pub kind: GranteeKind,
94 /// The person's or the team's id.
95 pub id: String,
96 pub role: PackageRole,
97}
98
99/// A repository of the package's workspace whose workflow jobs may use it.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct RepoAccess {
102 /// Its name in the workspace.
103 pub name: String,
104 pub role: PackageRole,
105}
106
107/// What a decision needs to know about a package, made or not yet.
108#[derive(Clone, Copy, Debug)]
109pub struct Target<'a> {
110 pub workspace: &'a str,
111 /// Without the workspace, for messages.
112 pub name: &'a str,
113 pub repo: Option<LinkedTo<'a>>,
114 /// For an unlinked package: whether it is public. A package not made
115 /// yet is private.
116 pub public: bool,
117 /// Whether the package is made: one that is not has no settings yet.
118 pub exists: bool,
119 /// For a linked package: whether it takes its repository's roles.
120 pub inherit: bool,
121 pub grants: &'a [Grant],
122 pub actions: &'a [RepoAccess],
123 /// The teams, by id, among those `grants` name, that the person asking
124 /// is in (their child teams' people included).
125 pub teams: &'a [String],
126}
127
128impl Target<'_> {
129 /// Whether anyone may pull it.
130 pub fn is_public(&self) -> bool {
131 match self.repo {
132 Some(repo) => !repo.private,
133 None => self.public,
134 }
135 }
136
137 fn label(&self) -> String {
138 format!("{}/{}", self.workspace, self.name)
139 }
140}
141
142fn from_repo_role(role: RepoRole) -> PackageRole {
143 match role {
144 RepoRole::Read | RepoRole::Triage => PackageRole::Read,
145 RepoRole::Write | RepoRole::Maintain => PackageRole::Write,
146 RepoRole::Admin => PackageRole::Admin,
147 }
148}
149
150/// What a member's membership of the workspace gives on its packages. A
151/// token's repository selection does not apply: the caller checked the
152/// workspace it is made for.
153fn workspace_role(user: &User, workspace: &str) -> Option<RepoRole> {
154 let mut user = user.clone();
155 if let Some(token) = user.token.as_deref_mut() {
156 token.reach = None;
157 }
158 // No repository has an empty id, so only the membership counts.
159 access::granted(&user, RepoRef { id: "", namespace: workspace, private: true })
160}
161
162/// Whether `user` administers every package of `workspace`: its owners, a
163/// service or g1t acting as the workspace, and a workspace token an owner
164/// gave Admin.
165fn owns(user: &User, workspace: &str) -> bool {
166 if !user.is_member(workspace) {
167 return false;
168 }
169 match user.kind {
170 PrincipalKind::System => true,
171 PrincipalKind::Workspace => user.token.as_deref().is_none_or(|token| token.admin),
172 _ => user.role_in(workspace) == Some(Role::Owner),
173 }
174}
175
176/// Whether a token reaches the package for more than a public pull: a
177/// narrowed one only inside the workspace it is made for and, for a linked
178/// package, its repository selection.
179fn reaches(token: Option<&TokenAccess>, target: &Target<'_>) -> bool {
180 let Some(token) = token else {
181 return true;
182 };
183 match target.repo {
184 Some(repo) => token.covers_repo(repo.id, target.workspace),
185 None => token.reach.as_ref().is_none_or(|reach| reach.owned_by(target.workspace)),
186 }
187}
188
189/// `user`'s role on the package, and the rule it comes from. A person (or
190/// a sandbox run, as the person it works for) only; tokens are checked
191/// before.
192pub fn role_of(user: &User, target: &Target<'_>) -> (Option<PackageRole>, &'static str) {
193 let public = target.is_public().then_some(PackageRole::Read);
194 if !reaches(user.token.as_deref(), target) {
195 return (public, "public");
196 }
197 if owns(user, target.workspace) {
198 return (Some(PackageRole::Admin), "owner");
199 }
200 let (base, rule) = match target.repo {
201 Some(repo) if target.inherit => (
202 access::granted(user, RepoRef { id: repo.id, namespace: target.workspace, private: repo.private }).map(from_repo_role),
203 "repository",
204 ),
205 Some(_) => (None, "package"),
206 // A member's base permission reaches Write at most: only owners
207 // administer the workspace's packages.
208 None => (workspace_role(user, target.workspace).map(|role| from_repo_role(role).min(PackageRole::Write)), "workspace"),
209 };
210 let granted = if user.kind == PrincipalKind::User {
211 target
212 .grants
213 .iter()
214 .filter(|grant| match grant.kind {
215 GranteeKind::User => grant.id == user.id,
216 GranteeKind::Team => target.teams.contains(&grant.id),
217 })
218 .map(|grant| grant.role)
219 .max()
220 } else {
221 None
222 };
223 let role = base.max(granted);
224 let rule = if granted.is_some() && granted >= base { "package" } else { rule };
225 match (role, public) {
226 (None, Some(read)) => (Some(read), "public"),
227 (role, public) => (role.max(public), rule),
228 }
229}
230
231/// A workflow job's token: only the repository the package is linked to,
232/// and those listed under its Manage Actions access, reach it.
233fn decide_job(token: &TokenAccess, target: &Target<'_>, action: Action) -> Decision {
234 let public = target.is_public();
235 let job = token.repo.as_deref().unwrap_or_default().to_lowercase();
236 let (owner, repo) = job.split_once('/').unwrap_or(("", job.as_str()));
237 let label = target.label();
238 let role = if !owner.eq_ignore_ascii_case(target.workspace) {
239 None
240 } else if target.repo.is_some_and(|linked| linked.name.eq_ignore_ascii_case(repo)) {
241 Some(PackageRole::Write)
242 } else if !target.exists {
243 // A new package: the workspace's own, which the job's repository
244 // is given access to when it is made, or one that will be linked
245 // to another repository, which it may not touch.
246 target.repo.is_none().then_some(PackageRole::Write)
247 } else {
248 target.actions.iter().find(|access| access.name.eq_ignore_ascii_case(repo)).map(|access| access.role)
249 };
250 let Some(role) = role else {
251 if action == Action::Pull && public {
252 return Decision::allow("public");
253 }
254 return Decision::deny(
255 "token:repository",
256 if owner.eq_ignore_ascii_case(target.workspace) {
257 format!(
258 "This token is a workflow job's in {job}, which has no access to the package {label}. An admin of the package can add {job} under the package's settings, in Manage Actions access."
259 )
260 } else {
261 format!("This token is a workflow job's in {job}: it cannot reach the packages of {}.", target.workspace)
262 },
263 );
264 };
265 let scoped = scopes::decide_packages(token, action.level(), public);
266 if !scoped.allowed {
267 return scoped;
268 }
269 match action {
270 _ if action.administers() => Decision::deny(
271 "token:job",
272 "A workflow job's token cannot delete packages or change their settings.",
273 ),
274 Action::Push if role < PackageRole::Write => Decision::deny(
275 "actions",
276 format!("{job} has the Read role on the package {label} in Manage Actions access: an admin of the package can give it Write."),
277 ),
278 _ => Decision::allow("actions"),
279 }
280}
281
282/// Whether `viewer` may do `action` to the package, with the rule and, for
283/// a refusal, the reason in words.
284pub fn decide(viewer: Option<&User>, target: &Target<'_>, action: Action) -> Decision {
285 let public = target.is_public();
286 let Some(mut user) = viewer.cloned() else {
287 return if action == Action::Pull && public {
288 Decision::allow("public")
289 } else if action == Action::Pull {
290 Decision::deny("anonymous", "Sign in to pull this package: docker login g1t.sh.")
291 } else {
292 Decision::deny("anonymous", "Sign in to push: docker login g1t.sh.")
293 };
294 };
295
296 // A sandbox run's token: only where its run may read or push code, and
297 // then as the person it works for.
298 if user.kind == PrincipalKind::Agent || user.acting.is_some() {
299 let Some(scope) = user.acting.as_ref().map(|acting| acting.scope.clone()) else {
300 return Decision::deny("agent", "This run's token cannot use packages.");
301 };
302 if action.administers() {
303 return Decision::deny("agent", "A run's token cannot delete packages or change their settings.");
304 }
305 let Some(repo) = target.repo else {
306 return Decision::deny("agent", "A run's token can use only the packages of the repository it runs in.");
307 };
308 let path = RepoPath { namespace: target.workspace.to_owned(), name: repo.name.to_owned() };
309 let decision = credentials::decide_git(&scope, &path, action == Action::Push);
310 if !decision.allowed {
311 return decision;
312 }
313 match credentials::as_person(&user) {
314 Some(person) => user = person,
315 None => return Decision::deny("agent", "This run's token cannot use packages."),
316 }
317 }
318
319 if let Some(token) = user.token.as_deref() {
320 if token.deploy_key.is_some() {
321 return if action == Action::Pull && public {
322 Decision::allow("public")
323 } else {
324 Decision::deny("token:deploy_key", "A deploy key reaches its repository's code only, never packages.")
325 };
326 }
327 if token.job.is_some() {
328 return decide_job(token, target, action);
329 }
330 // Any other token held to one repository reaches only that
331 // repository's packages, and the workspace's unlinked ones.
332 if let Some(repo) = target.repo
333 && let Some(refused) = scopes::decide_repo(token, &format!("{}/{}", target.workspace, repo.name))
334 {
335 return refused;
336 }
337 let decision = scopes::decide_packages(token, action.level(), public);
338 if !decision.allowed {
339 return decision;
340 }
341 }
342
343 if action != Action::Pull && user.kind == PrincipalKind::User && !user.verified {
344 return Decision::deny("unverified", "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.");
345 }
346
347 let (role, rule) = role_of(&user, target);
348 let needed = action.needs();
349 if role >= Some(needed) {
350 return Decision::allow(rule);
351 }
352 if role.is_none() {
353 return Decision::deny(rule_for(target), "This package does not exist, or you cannot see it.");
354 }
355 let reason = match (target.repo, action) {
356 (Some(repo), _) if target.inherit && target.exists && !action.administers() => format!(
357 "You need the {} role or higher on {}/{} to {} this package, or the role on the package itself.",
358 match needed {
359 PackageRole::Read => RepoRole::Read.label(),
360 PackageRole::Write => RepoRole::Write.label(),
361 PackageRole::Admin => RepoRole::Admin.label(),
362 },
363 target.workspace,
364 repo.name,
365 action.as_str()
366 ),
367 (Some(repo), _) if !target.exists => format!(
368 "You need the Write role or higher on {}/{} to push this package.",
369 target.workspace, repo.name
370 ),
371 (_, action) if action.administers() => format!(
372 "You need the Admin role on the package {} to {} it: an owner of {}, or an admin of the package{}, can give it to you.",
373 target.label(),
374 action.as_str(),
375 target.workspace,
376 if target.repo.is_some() && target.inherit { " or its repository" } else { "" }
377 ),
378 _ => format!("You need the Write role on the package {} to push it.", target.label()),
379 };
380 Decision::deny(rule_for(target), reason)
381}
382
383fn rule_for(target: &Target<'_>) -> &'static str {
384 match target.repo {
385 Some(_) if target.inherit => "repository",
386 Some(_) => "package",
387 None => "workspace",
388 }
389}
390
391/// Every action `viewer` may take, for the site.
392pub fn permissions(viewer: Option<&User>, target: &Target<'_>) -> PackagePermissions {
393 let may = |action| decide(viewer, target, action).allowed;
394 PackagePermissions {
395 pull: may(Action::Pull),
396 push: may(Action::Push),
397 delete: may(Action::Delete),
398 admin: may(Action::Admin),
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use g1t_contracts::Membership;
406 use g1t_contracts::access::{BasePermission, RepoGrant};
407 use g1t_contracts::credentials::{Acting, CredentialUse, GitGrant, Principal, RunBinding, RunCredentialKind};
408 use g1t_contracts::scopes::{JobToken, RepositorySelection, Scope, TokenAccess, TokenReach};
409
410 fn person(role: Role, base: Option<BasePermission>) -> User {
411 User {
412 id: "usr_1".into(),
413 username: "ana".into(),
414 verified: true,
415 workspaces: vec![Membership { role, base_permission: base, ..Membership::member("acme") }],
416 ..User::default()
417 }
418 }
419
420 fn outsider() -> User {
421 User { id: "usr_2".into(), username: "bo".into(), verified: true, ..User::default() }
422 }
423
424 const PRIVATE_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: true };
425 const PUBLIC_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: false };
426
427 fn linked(repo: LinkedTo<'static>) -> Target<'static> {
428 Target {
429 workspace: "acme",
430 name: "web",
431 repo: Some(repo),
432 public: false,
433 exists: true,
434 inherit: true,
435 grants: &[],
436 actions: &[],
437 teams: &[],
438 }
439 }
440
441 fn unlinked(public: bool) -> Target<'static> {
442 Target { repo: None, public, ..linked(PRIVATE_REPO) }
443 }
444
445 fn may(user: Option<&User>, target: Target<'_>, action: Action) -> bool {
446 decide(user, &target, action).allowed
447 }
448
449 #[test]
450 fn a_linked_package_follows_its_repository_roles() {
451 let member = person(Role::Member, None);
452 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Pull));
453 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Push));
454 assert!(!may(Some(&member), linked(PRIVATE_REPO), Action::Delete), "Write is not Admin");
455 assert!(!may(Some(&member), linked(PRIVATE_REPO), Action::Admin));
456 let reader = person(Role::Member, Some(BasePermission::Read));
457 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
458 let refused = decide(Some(&reader), &linked(PRIVATE_REPO), Action::Push);
459 assert!(refused.reason.unwrap().contains("Write role"));
460 let owner = person(Role::Owner, Some(BasePermission::Read));
461 assert!(may(Some(&owner), linked(PRIVATE_REPO), Action::Delete));
462 // A direct grant counts, for someone outside the workspace.
463 let mut collaborator = outsider();
464 collaborator.grants = vec![RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin, team: None }];
465 assert!(may(Some(&collaborator), linked(PRIVATE_REPO), Action::Delete));
466 assert!(!may(Some(&outsider()), linked(PRIVATE_REPO), Action::Pull));
467 }
468
469 #[test]
470 fn public_packages_pull_anonymously_and_nothing_else() {
471 assert!(may(None, linked(PUBLIC_REPO), Action::Pull));
472 assert!(!may(None, linked(PUBLIC_REPO), Action::Push));
473 assert!(!may(None, linked(PRIVATE_REPO), Action::Pull));
474 assert!(may(None, unlinked(true), Action::Pull));
475 assert!(!may(None, unlinked(false), Action::Pull));
476 assert!(may(Some(&outsider()), unlinked(true), Action::Pull));
477 assert!(!may(Some(&outsider()), unlinked(true), Action::Push));
478 }
479
480 #[test]
481 fn an_unlinked_package_is_the_workspaces_and_its_owners_administer() {
482 let member = person(Role::Member, None);
483 assert!(may(Some(&member), unlinked(false), Action::Push));
484 assert!(!may(Some(&member), unlinked(false), Action::Delete));
485 let none = person(Role::Member, Some(BasePermission::None));
486 assert!(!may(Some(&none), unlinked(false), Action::Pull));
487 let reader = person(Role::Member, Some(BasePermission::Read));
488 assert!(may(Some(&reader), unlinked(false), Action::Pull));
489 assert!(!may(Some(&reader), unlinked(false), Action::Push));
490 assert!(may(Some(&person(Role::Owner, None)), unlinked(false), Action::Delete));
491 assert!(may(Some(&person(Role::Owner, None)), unlinked(false), Action::Admin));
492 // Even a base permission of Admin does not make a member an owner.
493 assert!(!may(Some(&person(Role::Member, Some(BasePermission::Admin))), unlinked(false), Action::Delete));
494 let permissions = permissions(Some(&member), &unlinked(false));
495 assert_eq!(permissions, PackagePermissions { pull: true, push: true, delete: false, admin: false });
496 }
497
498 #[test]
499 fn grants_on_the_package_add_to_what_its_repository_gives() {
500 let grants = [
501 Grant { kind: GranteeKind::User, id: "usr_2".into(), role: PackageRole::Write },
502 Grant { kind: GranteeKind::Team, id: "team_ops".into(), role: PackageRole::Admin },
503 ];
504 let target = Target { grants: &grants, ..linked(PRIVATE_REPO) };
505 // An outsider given Write on the package pulls and pushes it.
506 assert!(may(Some(&outsider()), target, Action::Pull));
507 assert!(may(Some(&outsider()), target, Action::Push));
508 assert!(!may(Some(&outsider()), target, Action::Delete));
509 assert_eq!(decide(Some(&outsider()), &target, Action::Push).rule, "package");
510 // A reader of the repository in a team with Admin on the package.
511 let reader = person(Role::Member, Some(BasePermission::Read));
512 assert!(!may(Some(&reader), target, Action::Admin), "not in the team");
513 let teams = ["team_ops".to_owned()];
514 let in_team = Target { teams: &teams, ..target };
515 assert!(may(Some(&reader), in_team, Action::Admin));
516 assert!(may(Some(&reader), in_team, Action::Delete));
517 // A workspace's token is not a person: grants do not apply to it.
518 let mut workspace = workspace_token(false);
519 workspace.id = "usr_2".into();
520 assert!(!may(Some(&workspace), target, Action::Delete));
521 }
522
523 #[test]
524 fn without_inheriting_only_grants_and_owners_count() {
525 let grants = [Grant { kind: GranteeKind::User, id: "usr_2".into(), role: PackageRole::Read }];
526 let own = Target { inherit: false, grants: &grants, ..linked(PRIVATE_REPO) };
527 let member = person(Role::Member, Some(BasePermission::Write));
528 assert!(!may(Some(&member), own, Action::Pull), "the repository's Write no longer counts");
529 assert_eq!(decide(Some(&member), &own, Action::Pull).rule, "package");
530 assert!(may(Some(&outsider()), own, Action::Pull));
531 assert!(!may(Some(&outsider()), own, Action::Push));
532 assert!(may(Some(&person(Role::Owner, None)), own, Action::Admin), "owners always administer");
533 // A public repository's package still pulls for anyone.
534 let public = Target { inherit: false, ..linked(PUBLIC_REPO) };
535 assert!(may(None, public, Action::Pull));
536 assert!(!may(Some(&member), public, Action::Push));
537 }
538
539 fn workspace_token(admin: bool) -> User {
540 User {
541 id: "wsp_1".into(),
542 username: "acme".into(),
543 kind: PrincipalKind::Workspace,
544 verified: true,
545 workspaces: vec![Membership::member("acme")],
546 token: Some(Box::new(TokenAccess { admin, ..TokenAccess::full() })),
547 ..User::default()
548 }
549 }
550
551 #[test]
552 fn a_workspace_token_is_a_member_with_write_unless_given_admin() {
553 let workspace = workspace_token(false);
554 assert!(may(Some(&workspace), linked(PRIVATE_REPO), Action::Push));
555 assert!(may(Some(&workspace), unlinked(false), Action::Push));
556 assert!(!may(Some(&workspace), unlinked(false), Action::Delete));
557 assert!(!may(Some(&workspace), linked(PRIVATE_REPO), Action::Admin));
558 let admin = workspace_token(true);
559 assert!(may(Some(&admin), unlinked(false), Action::Delete));
560 assert!(may(Some(&admin), linked(PRIVATE_REPO), Action::Admin));
561 let other = Target { workspace: "other", ..unlinked(false) };
562 assert!(!may(Some(&admin), other, Action::Pull));
563 }
564
565 #[test]
566 fn a_tokens_scopes_limit_it_and_old_tokens_keep_working() {
567 let with = |scopes: &[Scope]| {
568 let mut user = person(Role::Owner, None);
569 user.token = Some(Box::new(TokenAccess {
570 token_id: "tok_1".into(),
571 scopes: Some(scopes.iter().map(|s| s.as_str().to_owned()).collect()),
572 ..TokenAccess::default()
573 }));
574 user
575 };
576 let code = with(&[Scope::CodeWrite]);
577 assert!(!may(Some(&code), linked(PRIVATE_REPO), Action::Pull));
578 assert!(may(Some(&code), linked(PUBLIC_REPO), Action::Pull));
579 let reader = with(&[Scope::PackagesRead]);
580 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
581 assert!(!may(Some(&reader), linked(PRIVATE_REPO), Action::Push));
582 let writer = with(&[Scope::PackagesWrite]);
583 assert!(may(Some(&writer), linked(PRIVATE_REPO), Action::Push));
584 assert!(!may(Some(&writer), linked(PRIVATE_REPO), Action::Delete));
585 assert!(may(Some(&writer), linked(PRIVATE_REPO), Action::Admin), "settings take packages:write and the Admin role");
586 assert!(may(Some(&with(&[Scope::PackagesDelete])), linked(PRIVATE_REPO), Action::Delete));
587 let mut legacy = person(Role::Owner, None);
588 legacy.token = Some(Box::new(TokenAccess { legacy: true, ..TokenAccess::full() }));
589 assert!(may(Some(&legacy), linked(PRIVATE_REPO), Action::Delete));
590 }
591
592 fn narrowed(selection: RepositorySelection, ids: &[&str], workspace: Option<&str>) -> User {
593 let mut user = person(Role::Owner, None);
594 user.token = Some(Box::new(TokenAccess {
595 token_id: "tok_fg".into(),
596 scopes: Some(vec!["packages:write".into()]),
597 reach: Some(TokenReach {
598 workspace: workspace.map(str::to_owned),
599 repositories: selection,
600 repo_ids: ids.iter().map(|id| (*id).to_owned()).collect(),
601 }),
602 ..TokenAccess::default()
603 }));
604 user
605 }
606
607 #[test]
608 fn a_narrowed_token_reaches_only_its_selection_and_workspace() {
609 let selected = narrowed(RepositorySelection::Selected, &["rep_1"], Some("acme"));
610 assert!(may(Some(&selected), linked(PRIVATE_REPO), Action::Push));
611 let api = LinkedTo { id: "rep_2", name: "api", private: true };
612 assert!(!may(Some(&selected), linked(api), Action::Pull), "outside its selection");
613 let public_api = LinkedTo { private: false, ..api };
614 assert!(may(Some(&selected), linked(public_api), Action::Pull), "a public one still pulls");
615 assert!(!may(Some(&selected), linked(public_api), Action::Push));
616 // The workspace's unlinked packages go by the workspace alone.
617 assert!(may(Some(&selected), unlinked(false), Action::Push));
618 let elsewhere = narrowed(RepositorySelection::All, &[], Some("other"));
619 assert!(!may(Some(&elsewhere), unlinked(false), Action::Pull));
620 assert!(may(Some(&elsewhere), unlinked(true), Action::Pull));
621 assert!(!may(Some(&elsewhere), linked(PRIVATE_REPO), Action::Pull));
622 let own_account = narrowed(RepositorySelection::All, &[], None);
623 assert!(!may(Some(&own_account), unlinked(false), Action::Pull));
624 // Grants on the package do not reach past the token's selection.
625 let grants = [Grant { kind: GranteeKind::User, id: "usr_1".into(), role: PackageRole::Admin }];
626 let granted = Target { grants: &grants, ..linked(api) };
627 assert!(!may(Some(&selected), granted, Action::Pull));
628 }
629
630 fn job(repo: &str, scopes: &[&str]) -> User {
631 let mut user = workspace_token(false);
632 user.token = Some(Box::new(TokenAccess {
633 token_id: "tok_job".into(),
634 scopes: Some(scopes.iter().map(|s| (*s).to_owned()).collect()),
635 repo: Some(repo.into()),
636 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
637 ..TokenAccess::default()
638 }));
639 user
640 }
641
642 #[test]
643 fn a_workflow_jobs_token_reaches_its_linked_repositorys_packages() {
644 let web = job("acme/web", &["packages:read", "packages:write"]);
645 assert!(may(Some(&web), linked(PRIVATE_REPO), Action::Push));
646 assert!(!may(Some(&web), linked(PRIVATE_REPO), Action::Delete));
647 let api = LinkedTo { id: "rep_2", name: "api", private: true };
648 let refused = decide(Some(&web), &linked(api), Action::Pull);
649 assert_eq!(refused.rule, "token:repository");
650 assert!(refused.reason.unwrap().contains("Manage Actions access"));
651 // A public package of another repository still pulls.
652 assert!(may(Some(&web), linked(LinkedTo { private: false, ..api }), Action::Pull));
653 // Another workspace's private package is out of reach.
654 let other = Target { workspace: "other", ..linked(PRIVATE_REPO) };
655 assert!(!may(Some(&web), other, Action::Pull));
656 }
657
658 #[test]
659 fn manage_actions_access_lets_other_repositories_read_or_write() {
660 let api = LinkedTo { id: "rep_2", name: "api", private: true };
661 let actions = [RepoAccess { name: "web".into(), role: PackageRole::Read }];
662 let target = Target { actions: &actions, ..linked(api) };
663 let web = job("acme/web", &["packages:read", "packages:write"]);
664 assert!(may(Some(&web), target, Action::Pull));
665 let refused = decide(Some(&web), &target, Action::Push);
666 assert!(!refused.allowed);
667 assert!(refused.reason.unwrap().contains("Read role"));
668 let writers = [RepoAccess { name: "web".into(), role: PackageRole::Write }];
669 assert!(may(Some(&web), Target { actions: &writers, ..linked(api) }, Action::Push));
670 // An unlisted repository is refused, an unlinked package's too.
671 let docs = job("acme/docs", &["packages:read", "packages:write"]);
672 assert!(!may(Some(&docs), target, Action::Pull));
673 let shared = Target { actions: &actions, ..unlinked(false) };
674 assert!(may(Some(&web), shared, Action::Pull));
675 assert!(!may(Some(&docs), shared, Action::Pull));
676 // The job's scopes still limit it.
677 let reading = job("acme/web", &["packages:read"]);
678 assert!(!may(Some(&reading), Target { actions: &writers, ..linked(api) }, Action::Push));
679 }
680
681 #[test]
682 fn a_job_may_make_a_new_workspace_package_but_not_another_repositorys() {
683 let web = job("acme/web", &["packages:write"]);
684 let new_unlinked = Target { exists: false, ..unlinked(false) };
685 assert!(may(Some(&web), new_unlinked, Action::Push));
686 let api = LinkedTo { id: "rep_2", name: "api", private: true };
687 let new_api = Target { exists: false, ..linked(api) };
688 assert!(!may(Some(&web), new_api, Action::Push));
689 let new_web = Target { exists: false, ..linked(PRIVATE_REPO) };
690 assert!(may(Some(&web), new_web, Action::Push));
691 }
692
693 #[test]
694 fn a_deploy_key_never_reaches_packages() {
695 let mut key = workspace_token(false);
696 key.token = Some(Box::new(TokenAccess { repo: Some("acme/web".into()), deploy_key: Some("key_1".into()), ..TokenAccess::full() }));
697 assert!(!may(Some(&key), linked(PRIVATE_REPO), Action::Pull));
698 assert!(may(Some(&key), linked(PUBLIC_REPO), Action::Pull));
699 assert_eq!(decide(Some(&key), &linked(PRIVATE_REPO), Action::Push).rule, "token:deploy_key");
700 }
701
702 #[test]
703 fn an_unverified_person_may_pull_but_not_push() {
704 let mut person = person(Role::Member, None);
705 person.verified = false;
706 assert!(may(Some(&person), linked(PRIVATE_REPO), Action::Pull));
707 assert!(decide(Some(&person), &linked(PRIVATE_REPO), Action::Push).reason.unwrap().contains("Confirm"));
708 }
709
710 fn agent(push: &[&str]) -> User {
711 let path = |name: &str| RepoPath { namespace: "acme".into(), name: name.into() };
712 User {
713 id: "agt_1".into(),
714 username: "g1t".into(),
715 kind: PrincipalKind::Agent,
716 verified: true,
717 workspaces: vec![Membership::member("acme")],
718 acting: Some(Box::new(Acting {
719 credential_id: "cred_1".into(),
720 agent: "g1t".into(),
721 on_behalf_of: Principal { id: "usr_1".into(), username: "ana".into() },
722 scope: g1t_contracts::identity::AgentScope {
723 repo: path("web"),
724 operations: vec![],
725 run: Some(RunBinding {
726 kind: RunCredentialKind::Checks,
727 usage: CredentialUse::Runner,
728 run_id: None,
729 number: None,
730 agent: "g1t".into(),
731 read: vec![],
732 push: push.iter().map(|name| GitGrant { repo: path(name), branch: None }).collect(),
733 system: false,
734 }),
735 },
736 })),
737 ..User::default()
738 }
739 }
740
741 #[test]
742 fn an_agent_run_pushes_only_its_own_repositorys_packages() {
743 let pushing = agent(&["web"]);
744 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Pull));
745 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Push));
746 assert!(!may(Some(&pushing), linked(PRIVATE_REPO), Action::Delete));
747 assert!(!may(Some(&pushing), linked(PRIVATE_REPO), Action::Admin));
748 assert!(!may(Some(&pushing), unlinked(false), Action::Push), "only packages of a repository");
749 let other = LinkedTo { id: "rep_2", name: "api", private: true };
750 assert!(!may(Some(&pushing), linked(other), Action::Push));
751 let reading = agent(&[]);
752 assert!(may(Some(&reading), linked(PRIVATE_REPO), Action::Pull));
753 assert!(!may(Some(&reading), linked(PRIVATE_REPO), Action::Push));
754 }
755}