Skip to content
398 linesCodeBlameRaw
1//! Who may pull, push and delete a package.
2//!
3//! A package linked to a repository has its visibility and roles: Read
4//! pulls, Write pushes, Admin deletes and changes its settings. An unlinked
5//! one is its workspace's: members by the base permission, owners delete,
6//! anyone pulls a public one. A token is limited further by its scopes
7//! (`packages:read`, `packages:write`, `packages:delete`), and an agent's
8//! run token by its run: it may push only where its run may push code.
9
10use g1t_contracts::access::{self, RepoRef, RepoRole};
11use g1t_contracts::credentials::{self, Decision};
12use g1t_contracts::packages::PackagePermissions;
13use g1t_contracts::repos::RepoPath;
14use g1t_contracts::scopes::{self, Level};
15use g1t_contracts::{PrincipalKind, Role, User};
16use serde::{Deserialize, Serialize};
17
18/// What is done to a package, as registry tokens name it.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Action {
22 Pull,
23 Push,
24 Delete,
25}
26
27impl Action {
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Action::Pull => "pull",
31 Action::Push => "push",
32 Action::Delete => "delete",
33 }
34 }
35
36 fn level(self) -> Level {
37 match self {
38 Action::Pull => Level::Read,
39 Action::Push => Level::Write,
40 Action::Delete => Level::Delete,
41 }
42 }
43}
44
45/// The repository a package is linked to, or would be on its first push.
46#[derive(Clone, Copy, Debug)]
47pub struct LinkedTo<'a> {
48 pub id: &'a str,
49 pub name: &'a str,
50 pub private: bool,
51}
52
53/// What a decision needs to know about a package, made or not yet.
54#[derive(Clone, Copy, Debug)]
55pub struct Target<'a> {
56 pub workspace: &'a str,
57 pub repo: Option<LinkedTo<'a>>,
58 /// For an unlinked package: whether it is public. A package not made
59 /// yet is private.
60 pub public: bool,
61}
62
63impl Target<'_> {
64 /// Whether anyone may pull it.
65 pub fn is_public(&self) -> bool {
66 match self.repo {
67 Some(repo) => !repo.private,
68 None => self.public,
69 }
70 }
71}
72
73/// What a member's membership of the workspace gives on its packages.
74fn workspace_role(user: &User, workspace: &str) -> Option<RepoRole> {
75 // No repository has an empty id, so only the membership counts.
76 access::granted(user, RepoRef { id: "", namespace: workspace, private: true })
77}
78
79/// Whether `user` may delete an unlinked package of `workspace`, and change
80/// its settings: its owners, and the workspace's own token.
81fn owns(user: &User, workspace: &str) -> bool {
82 matches!(user.kind, PrincipalKind::Workspace | PrincipalKind::System) && user.is_member(workspace)
83 || user.role_in(workspace) == Some(Role::Owner)
84}
85
86/// Whether `viewer` may do `action` to the package, with the rule and, for
87/// a refusal, the reason in words.
88pub fn decide(viewer: Option<&User>, target: &Target<'_>, action: Action) -> Decision {
89 let public = target.is_public();
90 let Some(mut user) = viewer.cloned() else {
91 return if action == Action::Pull && public {
92 Decision::allow("public")
93 } else if action == Action::Pull {
94 Decision::deny("anonymous", "Sign in to pull this package: docker login g1t.sh.")
95 } else {
96 Decision::deny("anonymous", "Sign in to push: docker login g1t.sh.")
97 };
98 };
99
100 // An agent's run token: only where its run may read or push code, and
101 // then as the person it works for.
102 if user.kind == PrincipalKind::Agent {
103 let Some(scope) = user.acting.as_ref().map(|acting| acting.scope.clone()) else {
104 return Decision::deny("agent", "This agent token cannot use packages.");
105 };
106 if action == Action::Delete {
107 return Decision::deny("agent", "A g1t agent cannot delete packages.");
108 }
109 let Some(repo) = target.repo else {
110 return Decision::deny("agent", "A g1t agent can use only the packages of the repository it works on.");
111 };
112 let path = RepoPath { namespace: target.workspace.to_owned(), name: repo.name.to_owned() };
113 let decision = credentials::decide_git(&scope, &path, action == Action::Push);
114 if !decision.allowed {
115 return decision;
116 }
117 match credentials::as_person(&user) {
118 Some(person) => user = person,
119 None => return Decision::deny("agent", "This agent token cannot use packages."),
120 }
121 }
122
123 if let Some(token) = user.token.as_deref() {
124 // A workflow job's token: a package linked to another repository is
125 // out of its reach; the workspace's unlinked ones follow its scopes.
126 if let Some(repo) = target.repo
127 && let Some(refused) = scopes::decide_repo(token, &format!("{}/{}", target.workspace, repo.name))
128 {
129 return refused;
130 }
131 let decision = scopes::decide_packages(token, action.level(), public);
132 if !decision.allowed {
133 return decision;
134 }
135 }
136
137 if action != Action::Pull && user.kind == PrincipalKind::User && !user.verified {
138 return Decision::deny("unverified", "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.");
139 }
140
141 match target.repo {
142 Some(repo) => {
143 let role = access::permission(
144 Some(&user),
145 RepoRef { id: repo.id, namespace: target.workspace, private: repo.private },
146 );
147 let needed = match action {
148 Action::Pull => RepoRole::Read,
149 Action::Push => RepoRole::Write,
150 Action::Delete => RepoRole::Admin,
151 };
152 if role >= Some(needed) {
153 Decision::allow("repository")
154 } else if role.is_none() {
155 Decision::deny("repository", "This package does not exist, or you cannot see it.")
156 } else {
157 Decision::deny(
158 "repository",
159 format!(
160 "You need the {} role or higher on {}/{} to {} this package.",
161 needed.label(),
162 target.workspace,
163 repo.name,
164 action.as_str()
165 ),
166 )
167 }
168 }
169 None => {
170 let role = workspace_role(&user, target.workspace);
171 let allowed = match action {
172 Action::Pull => public || role >= Some(RepoRole::Read),
173 Action::Push => role >= Some(RepoRole::Write),
174 Action::Delete => owns(&user, target.workspace),
175 };
176 if allowed {
177 Decision::allow(if public && role.is_none() { "public" } else { "workspace" })
178 } else if !public && role.is_none() {
179 Decision::deny("workspace", "This package does not exist, or you cannot see it.")
180 } else if action == Action::Delete {
181 Decision::deny("workspace", format!("Only an owner of {} can delete its packages.", target.workspace))
182 } else {
183 Decision::deny("workspace", format!("You need write access to {} to push its packages.", target.workspace))
184 }
185 }
186 }
187}
188
189/// Every action `viewer` may take, for the site.
190pub fn permissions(viewer: Option<&User>, target: &Target<'_>) -> PackagePermissions {
191 let may = |action| decide(viewer, target, action).allowed;
192 let delete = may(Action::Delete);
193 PackagePermissions {
194 pull: may(Action::Pull),
195 push: may(Action::Push),
196 delete,
197 admin: delete,
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use g1t_contracts::Membership;
205 use g1t_contracts::access::{BasePermission, RepoGrant};
206 use g1t_contracts::credentials::{Acting, CredentialUse, GitGrant, Principal, RunBinding, RunCredentialKind};
207 use g1t_contracts::scopes::{Scope, TokenAccess};
208
209 fn person(role: Role, base: Option<BasePermission>) -> User {
210 User {
211 id: "usr_1".into(),
212 username: "ana".into(),
213 verified: true,
214 workspaces: vec![Membership { role, base_permission: base, ..Membership::member("acme") }],
215 ..User::default()
216 }
217 }
218
219 fn outsider() -> User {
220 User { id: "usr_2".into(), username: "bo".into(), verified: true, ..User::default() }
221 }
222
223 const PRIVATE_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: true };
224 const PUBLIC_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: false };
225
226 fn linked(repo: LinkedTo<'static>) -> Target<'static> {
227 Target { workspace: "acme", repo: Some(repo), public: false }
228 }
229
230 fn unlinked(public: bool) -> Target<'static> {
231 Target { workspace: "acme", repo: None, public }
232 }
233
234 fn may(user: Option<&User>, target: Target<'_>, action: Action) -> bool {
235 decide(user, &target, action).allowed
236 }
237
238 #[test]
239 fn a_linked_package_follows_its_repository_roles() {
240 let member = person(Role::Member, None);
241 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Pull));
242 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Push));
243 assert!(!may(Some(&member), linked(PRIVATE_REPO), Action::Delete), "Write is not Admin");
244 let reader = person(Role::Member, Some(BasePermission::Read));
245 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
246 let refused = decide(Some(&reader), &linked(PRIVATE_REPO), Action::Push);
247 assert!(refused.reason.unwrap().contains("Write role"));
248 let owner = person(Role::Owner, Some(BasePermission::Read));
249 assert!(may(Some(&owner), linked(PRIVATE_REPO), Action::Delete));
250 // A direct grant counts, for someone outside the workspace.
251 let mut collaborator = outsider();
252 collaborator.grants = vec![RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin, team: None }];
253 assert!(may(Some(&collaborator), linked(PRIVATE_REPO), Action::Delete));
254 assert!(!may(Some(&outsider()), linked(PRIVATE_REPO), Action::Pull));
255 }
256
257 #[test]
258 fn public_packages_pull_anonymously_and_nothing_else() {
259 assert!(may(None, linked(PUBLIC_REPO), Action::Pull));
260 assert!(!may(None, linked(PUBLIC_REPO), Action::Push));
261 assert!(!may(None, linked(PRIVATE_REPO), Action::Pull));
262 assert!(may(None, unlinked(true), Action::Pull));
263 assert!(!may(None, unlinked(false), Action::Pull));
264 assert!(may(Some(&outsider()), unlinked(true), Action::Pull));
265 assert!(!may(Some(&outsider()), unlinked(true), Action::Push));
266 }
267
268 #[test]
269 fn an_unlinked_package_is_the_workspaces_and_its_owners_delete() {
270 let member = person(Role::Member, None);
271 assert!(may(Some(&member), unlinked(false), Action::Push));
272 assert!(!may(Some(&member), unlinked(false), Action::Delete));
273 let none = person(Role::Member, Some(BasePermission::None));
274 assert!(!may(Some(&none), unlinked(false), Action::Pull));
275 let reader = person(Role::Member, Some(BasePermission::Read));
276 assert!(may(Some(&reader), unlinked(false), Action::Pull));
277 assert!(!may(Some(&reader), unlinked(false), Action::Push));
278 assert!(may(Some(&person(Role::Owner, None)), unlinked(false), Action::Delete));
279 // Even a base permission of Admin does not make a member an owner.
280 assert!(!may(Some(&person(Role::Member, Some(BasePermission::Admin))), unlinked(false), Action::Delete));
281 let permissions = permissions(Some(&member), &unlinked(false));
282 assert_eq!(permissions, PackagePermissions { pull: true, push: true, delete: false, admin: false });
283 }
284
285 #[test]
286 fn a_workspace_token_such_as_g1t_token_does_what_an_owner_can() {
287 let workspace = User {
288 id: "wsp_1".into(),
289 username: "acme".into(),
290 kind: PrincipalKind::Workspace,
291 verified: true,
292 workspaces: vec![Membership::member("acme")],
293 token: Some(Box::new(TokenAccess::full())),
294 ..User::default()
295 };
296 assert!(may(Some(&workspace), linked(PRIVATE_REPO), Action::Push));
297 assert!(may(Some(&workspace), unlinked(false), Action::Push));
298 assert!(may(Some(&workspace), unlinked(false), Action::Delete));
299 let other = Target { workspace: "other", repo: None, public: false };
300 assert!(!may(Some(&workspace), other, Action::Pull));
301 }
302
303 #[test]
304 fn a_tokens_scopes_limit_it_and_old_tokens_keep_working() {
305 let with = |scopes: &[Scope]| {
306 let mut user = person(Role::Owner, None);
307 user.token = Some(Box::new(TokenAccess {
308 token_id: "tok_1".into(),
309 scopes: Some(scopes.iter().map(|s| s.as_str().to_owned()).collect()),
310 legacy: false,
311 name: None,
312 ..TokenAccess::default()
313 }));
314 user
315 };
316 let code = with(&[Scope::CodeWrite]);
317 assert!(!may(Some(&code), linked(PRIVATE_REPO), Action::Pull));
318 assert!(may(Some(&code), linked(PUBLIC_REPO), Action::Pull));
319 let reader = with(&[Scope::PackagesRead]);
320 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
321 assert!(!may(Some(&reader), linked(PRIVATE_REPO), Action::Push));
322 let writer = with(&[Scope::PackagesWrite]);
323 assert!(may(Some(&writer), linked(PRIVATE_REPO), Action::Push));
324 assert!(!may(Some(&writer), linked(PRIVATE_REPO), Action::Delete));
325 assert!(may(Some(&with(&[Scope::PackagesDelete])), linked(PRIVATE_REPO), Action::Delete));
326 let mut legacy = person(Role::Owner, None);
327 legacy.token = Some(Box::new(TokenAccess { legacy: true, ..TokenAccess::full() }));
328 assert!(may(Some(&legacy), linked(PRIVATE_REPO), Action::Delete));
329 }
330
331 #[test]
332 fn a_workflow_jobs_token_reaches_its_repositorys_packages_only() {
333 let mut job = person(Role::Owner, None);
334 job.token = Some(Box::new(TokenAccess {
335 token_id: "tok_1".into(),
336 scopes: Some(vec!["packages:read".into(), "packages:write".into()]),
337 repo: Some("acme/web".into()),
338 ..TokenAccess::default()
339 }));
340 assert!(may(Some(&job), linked(PRIVATE_REPO), Action::Push));
341 assert!(may(Some(&job), unlinked(false), Action::Push), "the workspace's own packages follow its scopes");
342 let api = LinkedTo { id: "rep_2", name: "api", private: true };
343 assert_eq!(decide(Some(&job), &linked(api), Action::Pull).rule, "token:repository");
344 }
345
346 #[test]
347 fn an_unverified_person_may_pull_but_not_push() {
348 let mut person = person(Role::Member, None);
349 person.verified = false;
350 assert!(may(Some(&person), linked(PRIVATE_REPO), Action::Pull));
351 assert!(decide(Some(&person), &linked(PRIVATE_REPO), Action::Push).reason.unwrap().contains("Confirm"));
352 }
353
354 fn agent(push: &[&str]) -> User {
355 let path = |name: &str| RepoPath { namespace: "acme".into(), name: name.into() };
356 User {
357 id: "agt_1".into(),
358 username: "g1t".into(),
359 kind: PrincipalKind::Agent,
360 verified: true,
361 workspaces: vec![Membership::member("acme")],
362 acting: Some(Box::new(Acting {
363 credential_id: "cred_1".into(),
364 agent: "g1t".into(),
365 on_behalf_of: Principal { id: "usr_1".into(), username: "ana".into() },
366 scope: g1t_contracts::identity::AgentScope {
367 repo: path("web"),
368 operations: vec![],
369 run: Some(RunBinding {
370 kind: RunCredentialKind::Implement,
371 usage: CredentialUse::Runner,
372 run_id: None,
373 number: None,
374 agent: "g1t".into(),
375 read: vec![],
376 push: push.iter().map(|name| GitGrant { repo: path(name), branch: None }).collect(),
377 system: false,
378 }),
379 },
380 })),
381 ..User::default()
382 }
383 }
384
385 #[test]
386 fn an_agent_run_pushes_only_its_own_repositorys_packages() {
387 let pushing = agent(&["web"]);
388 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Pull));
389 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Push));
390 assert!(!may(Some(&pushing), linked(PRIVATE_REPO), Action::Delete));
391 assert!(!may(Some(&pushing), unlinked(false), Action::Push), "only packages of a repository");
392 let other = LinkedTo { id: "rep_2", name: "api", private: true };
393 assert!(!may(Some(&pushing), linked(other), Action::Push));
394 let reading = agent(&[]);
395 assert!(may(Some(&reading), linked(PRIVATE_REPO), Action::Pull));
396 assert!(!may(Some(&reading), linked(PRIVATE_REPO), Action::Push));
397 }
398}