Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 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 | ||
| 10 | use g1t_contracts::access::{self, RepoRef, RepoRole}; | |
| 11 | use g1t_contracts::credentials::{self, Decision}; | |
| 12 | use g1t_contracts::packages::PackagePermissions; | |
| 13 | use g1t_contracts::repos::RepoPath; | |
| 14 | use g1t_contracts::scopes::{self, Level}; | |
| 15 | use g1t_contracts::{PrincipalKind, Role, User}; | |
| 16 | use 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")] | |
| 21 | pub enum Action { | |
| 22 | Pull, | |
| 23 | Push, | |
| 24 | Delete, | |
| 25 | } | |
| 26 | ||
| 27 | impl 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)] | |
| 47 | pub 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)] | |
| 55 | pub 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 | ||
| 63 | impl 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. | |
| 74 | fn 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. | |
| 81 | fn 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. | |
| 88 | pub 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. | |
| 183 | pub 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)] | |
| 195 | mod 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 | } |