g1t/services/packages/src/access.rs

374 lines16,038 bytesCodeBlame
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 let decision = scopes::decide_packages(token, action.level(), public);
125 if !decision.allowed {
126 return decision;
127 }
128 }
129
130 if action != Action::Pull && user.kind == PrincipalKind::User && !user.verified {
131 return Decision::deny("unverified", "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.");
132 }
133
134 match target.repo {
135 Some(repo) => {
136 let role = access::permission(
137 Some(&user),
138 RepoRef { id: repo.id, namespace: target.workspace, private: repo.private },
139 );
140 let needed = match action {
141 Action::Pull => RepoRole::Read,
142 Action::Push => RepoRole::Write,
143 Action::Delete => RepoRole::Admin,
144 };
145 if role >= Some(needed) {
146 Decision::allow("repository")
147 } else if role.is_none() {
148 Decision::deny("repository", "This package does not exist, or you cannot see it.")
149 } else {
150 Decision::deny(
151 "repository",
152 format!(
153 "You need the {} role or higher on {}/{} to {} this package.",
154 needed.label(),
155 target.workspace,
156 repo.name,
157 action.as_str()
158 ),
159 )
160 }
161 }
162 None => {
163 let role = workspace_role(&user, target.workspace);
164 let allowed = match action {
165 Action::Pull => public || role >= Some(RepoRole::Read),
166 Action::Push => role >= Some(RepoRole::Write),
167 Action::Delete => owns(&user, target.workspace),
168 };
169 if allowed {
170 Decision::allow(if public && role.is_none() { "public" } else { "workspace" })
171 } else if !public && role.is_none() {
172 Decision::deny("workspace", "This package does not exist, or you cannot see it.")
173 } else if action == Action::Delete {
174 Decision::deny("workspace", format!("Only an owner of {} can delete its packages.", target.workspace))
175 } else {
176 Decision::deny("workspace", format!("You need write access to {} to push its packages.", target.workspace))
177 }
178 }
179 }
180}
181
182/// Every action `viewer` may take, for the site.
183pub fn permissions(viewer: Option<&User>, target: &Target<'_>) -> PackagePermissions {
184 let may = |action| decide(viewer, target, action).allowed;
185 let delete = may(Action::Delete);
186 PackagePermissions {
187 pull: may(Action::Pull),
188 push: may(Action::Push),
189 delete,
190 admin: delete,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use g1t_contracts::Membership;
198 use g1t_contracts::access::{BasePermission, RepoGrant};
199 use g1t_contracts::credentials::{Acting, CredentialUse, GitGrant, Principal, RunBinding, RunCredentialKind};
200 use g1t_contracts::scopes::{Scope, TokenAccess};
201
202 fn person(role: Role, base: Option<BasePermission>) -> User {
203 User {
204 id: "usr_1".into(),
205 username: "ana".into(),
206 verified: true,
207 workspaces: vec![Membership { role, base_permission: base, ..Membership::member("acme") }],
208 ..User::default()
209 }
210 }
211
212 fn outsider() -> User {
213 User { id: "usr_2".into(), username: "bo".into(), verified: true, ..User::default() }
214 }
215
216 const PRIVATE_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: true };
217 const PUBLIC_REPO: LinkedTo<'static> = LinkedTo { id: "rep_1", name: "web", private: false };
218
219 fn linked(repo: LinkedTo<'static>) -> Target<'static> {
220 Target { workspace: "acme", repo: Some(repo), public: false }
221 }
222
223 fn unlinked(public: bool) -> Target<'static> {
224 Target { workspace: "acme", repo: None, public }
225 }
226
227 fn may(user: Option<&User>, target: Target<'_>, action: Action) -> bool {
228 decide(user, &target, action).allowed
229 }
230
231 #[test]
232 fn a_linked_package_follows_its_repository_roles() {
233 let member = person(Role::Member, None);
234 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Pull));
235 assert!(may(Some(&member), linked(PRIVATE_REPO), Action::Push));
236 assert!(!may(Some(&member), linked(PRIVATE_REPO), Action::Delete), "Write is not Admin");
237 let reader = person(Role::Member, Some(BasePermission::Read));
238 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
239 let refused = decide(Some(&reader), &linked(PRIVATE_REPO), Action::Push);
240 assert!(refused.reason.unwrap().contains("Write role"));
241 let owner = person(Role::Owner, Some(BasePermission::Read));
242 assert!(may(Some(&owner), linked(PRIVATE_REPO), Action::Delete));
243 // A direct grant counts, for someone outside the workspace.
244 let mut collaborator = outsider();
245 collaborator.grants = vec![RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin }];
246 assert!(may(Some(&collaborator), linked(PRIVATE_REPO), Action::Delete));
247 assert!(!may(Some(&outsider()), linked(PRIVATE_REPO), Action::Pull));
248 }
249
250 #[test]
251 fn public_packages_pull_anonymously_and_nothing_else() {
252 assert!(may(None, linked(PUBLIC_REPO), Action::Pull));
253 assert!(!may(None, linked(PUBLIC_REPO), Action::Push));
254 assert!(!may(None, linked(PRIVATE_REPO), Action::Pull));
255 assert!(may(None, unlinked(true), Action::Pull));
256 assert!(!may(None, unlinked(false), Action::Pull));
257 assert!(may(Some(&outsider()), unlinked(true), Action::Pull));
258 assert!(!may(Some(&outsider()), unlinked(true), Action::Push));
259 }
260
261 #[test]
262 fn an_unlinked_package_is_the_workspaces_and_its_owners_delete() {
263 let member = person(Role::Member, None);
264 assert!(may(Some(&member), unlinked(false), Action::Push));
265 assert!(!may(Some(&member), unlinked(false), Action::Delete));
266 let none = person(Role::Member, Some(BasePermission::None));
267 assert!(!may(Some(&none), unlinked(false), Action::Pull));
268 let reader = person(Role::Member, Some(BasePermission::Read));
269 assert!(may(Some(&reader), unlinked(false), Action::Pull));
270 assert!(!may(Some(&reader), unlinked(false), Action::Push));
271 assert!(may(Some(&person(Role::Owner, None)), unlinked(false), Action::Delete));
272 // Even a base permission of Admin does not make a member an owner.
273 assert!(!may(Some(&person(Role::Member, Some(BasePermission::Admin))), unlinked(false), Action::Delete));
274 let permissions = permissions(Some(&member), &unlinked(false));
275 assert_eq!(permissions, PackagePermissions { pull: true, push: true, delete: false, admin: false });
276 }
277
278 #[test]
279 fn a_workspace_token_such_as_g1t_token_does_what_an_owner_can() {
280 let workspace = User {
281 id: "wsp_1".into(),
282 username: "acme".into(),
283 kind: PrincipalKind::Workspace,
284 verified: true,
285 workspaces: vec![Membership::member("acme")],
286 token: Some(Box::new(TokenAccess::full())),
287 ..User::default()
288 };
289 assert!(may(Some(&workspace), linked(PRIVATE_REPO), Action::Push));
290 assert!(may(Some(&workspace), unlinked(false), Action::Push));
291 assert!(may(Some(&workspace), unlinked(false), Action::Delete));
292 let other = Target { workspace: "other", repo: None, public: false };
293 assert!(!may(Some(&workspace), other, Action::Pull));
294 }
295
296 #[test]
297 fn a_tokens_scopes_limit_it_and_old_tokens_keep_working() {
298 let with = |scopes: &[Scope]| {
299 let mut user = person(Role::Owner, None);
300 user.token = Some(Box::new(TokenAccess {
301 token_id: "tok_1".into(),
302 scopes: Some(scopes.iter().map(|s| s.as_str().to_owned()).collect()),
303 legacy: false,
304 }));
305 user
306 };
307 let code = with(&[Scope::CodeWrite]);
308 assert!(!may(Some(&code), linked(PRIVATE_REPO), Action::Pull));
309 assert!(may(Some(&code), linked(PUBLIC_REPO), Action::Pull));
310 let reader = with(&[Scope::PackagesRead]);
311 assert!(may(Some(&reader), linked(PRIVATE_REPO), Action::Pull));
312 assert!(!may(Some(&reader), linked(PRIVATE_REPO), Action::Push));
313 let writer = with(&[Scope::PackagesWrite]);
314 assert!(may(Some(&writer), linked(PRIVATE_REPO), Action::Push));
315 assert!(!may(Some(&writer), linked(PRIVATE_REPO), Action::Delete));
316 assert!(may(Some(&with(&[Scope::PackagesDelete])), linked(PRIVATE_REPO), Action::Delete));
317 let mut legacy = person(Role::Owner, None);
318 legacy.token = Some(Box::new(TokenAccess { legacy: true, ..TokenAccess::full() }));
319 assert!(may(Some(&legacy), linked(PRIVATE_REPO), Action::Delete));
320 }
321
322 #[test]
323 fn an_unverified_person_may_pull_but_not_push() {
324 let mut person = person(Role::Member, None);
325 person.verified = false;
326 assert!(may(Some(&person), linked(PRIVATE_REPO), Action::Pull));
327 assert!(decide(Some(&person), &linked(PRIVATE_REPO), Action::Push).reason.unwrap().contains("Confirm"));
328 }
329
330 fn agent(push: &[&str]) -> User {
331 let path = |name: &str| RepoPath { namespace: "acme".into(), name: name.into() };
332 User {
333 id: "agt_1".into(),
334 username: "g1t".into(),
335 kind: PrincipalKind::Agent,
336 verified: true,
337 workspaces: vec![Membership::member("acme")],
338 acting: Some(Box::new(Acting {
339 credential_id: "cred_1".into(),
340 agent: "g1t".into(),
341 on_behalf_of: Principal { id: "usr_1".into(), username: "ana".into() },
342 scope: g1t_contracts::identity::AgentScope {
343 repo: path("web"),
344 operations: vec![],
345 run: Some(RunBinding {
346 kind: RunCredentialKind::Implement,
347 usage: CredentialUse::Runner,
348 run_id: None,
349 number: None,
350 agent: "g1t".into(),
351 read: vec![],
352 push: push.iter().map(|name| GitGrant { repo: path(name), branch: None }).collect(),
353 system: false,
354 }),
355 },
356 })),
357 ..User::default()
358 }
359 }
360
361 #[test]
362 fn an_agent_run_pushes_only_its_own_repositorys_packages() {
363 let pushing = agent(&["web"]);
364 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Pull));
365 assert!(may(Some(&pushing), linked(PRIVATE_REPO), Action::Push));
366 assert!(!may(Some(&pushing), linked(PRIVATE_REPO), Action::Delete));
367 assert!(!may(Some(&pushing), unlinked(false), Action::Push), "only packages of a repository");
368 let other = LinkedTo { id: "rep_2", name: "api", private: true };
369 assert!(!may(Some(&pushing), linked(other), Action::Push));
370 let reading = agent(&[]);
371 assert!(may(Some(&reading), linked(PRIVATE_REPO), Action::Pull));
372 assert!(!may(Some(&reading), linked(PRIVATE_REPO), Action::Push));
373 }
374}