Skip to content

g1t/services/packages/src/access.rs

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