Skip to content
847 linesCodeBlameRaw

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.

Merge packages: roles, Actions access, source label, soft delete, API1//! A package's own settings: who has a role on it (people and teams), its
2//! Manage Actions access, whether a linked one inherits its repository's
3//! roles; deleting and restoring packages and versions, and the purge 30
4//! days on; and linking an image to the repository its source label names.
5//!
6//! Decisions themselves are access.rs's. What it needs beyond the package
7//! row (its grants, its Actions access, the teams the person asking is in)
8//! is read only when a first decision without it refuses, so a registry
9//! request by someone the repository already allows costs nothing more.
10
11use std::collections::{HashMap, HashSet};
12
13use g1t_contracts::audit::Surface;
14use g1t_contracts::credentials::Decision;
15use g1t_contracts::identity::UsernameArgs;
16use g1t_contracts::packages::*;
17use g1t_contracts::teams::{ResolveTeamsArgs, ResolvedTeam};
18use g1t_contracts::time::{parse_rfc3339, rfc3339};
19use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
20use g1t_kit::now_ms;
21use worker::Result;
22
23use crate::access::{self, Action, Grant, RepoAccess};
24use crate::db::{PackageRow, TagRow, VersionRow};
25use crate::{Caller, Packages, TargetOf, not_found};
26
27const DAY_MS: u64 = 24 * 60 * 60 * 1000;
28/// How much one purge removes.
29const PURGE_BATCH: u32 = 100;
30/// How many deleted versions the settings show.
31const DELETED_SHOWN: u32 = 200;
32
33/// When something deleted at `deleted_at` is purged: it can be restored
34/// until then.
35pub(crate) fn purge_at(deleted_at: &str) -> Option<String> {
36 parse_rfc3339(deleted_at).map(|at| rfc3339(at + RESTORE_DAYS * DAY_MS))
37}
38
39/// What was deleted before this time is purged now.
40pub(crate) fn purge_cutoff(now: u64) -> String {
41 rfc3339(now.saturating_sub(RESTORE_DAYS * DAY_MS))
42}
43
44/// Whether something deleted at `deleted_at` can still be restored at `now`.
45pub(crate) fn restorable(deleted_at: &str, now: u64) -> bool {
46 deleted_at >= purge_cutoff(now).as_str()
47}
48
49/// A version as the site and the API show it.
50pub(crate) fn version_of(package: &PackageRow, version: VersionRow, tags: &[TagRow]) -> PackageVersion {
51 // A yanked or unlisted version reads as deprecated: still there for
52 // lockfiles that name it, no longer picked for new ones.
53 let withdrawn = match package.ecosystem.as_str() {
54 "nuget" => "Unlisted: still restored by projects that name it, no longer shown in search.",
55 "rubygems" => "Yanked: Bundler no longer picks this version for new lockfiles.",
56 _ => "Yanked: Cargo no longer picks this version for new lockfiles.",
57 };
58 let meta = version.meta();
59 let text = |key: &str| meta[key].as_str().map(str::to_owned);
60 PackageVersion {
61 tags: tags.iter().filter(|tag| tag.version_id == version.id).map(|tag| tag.tag.clone()).collect(),
62 media_type: text("media_type"),
63 artifact_type: text("artifact_type"),
64 platforms: meta["platforms"]
65 .as_array()
66 .map(|list| list.iter().filter_map(|p| p.as_str().map(str::to_owned)).collect())
67 .unwrap_or_default(),
68 purge_at: version.deleted_at.as_deref().and_then(purge_at),
69 id: version.id,
70 version: version.version,
71 digest: version.digest,
72 size: version.size,
73 subject: version.subject,
74 published_by: version.published_by,
75 published_at: version.published_at,
76 deprecated: if version.yanked != 0 { Some(withdrawn.to_owned()) } else { version.deprecated },
77 symbols: meta["symbols"] == true,
78 downloads: Some(version.downloads),
79 deleted_at: version.deleted_at,
80 deleted_by: version.deleted_by,
81 }
82}
83
84/// The person whose team memberships count: a person, or the one an
85/// agent's run works for.
86fn person_id(user: &User) -> Option<String> {
87 match user.kind {
88 PrincipalKind::User => Some(user.id.clone()),
89 PrincipalKind::Agent => user.acting.as_ref().map(|acting| acting.on_behalf_of.id.clone()),
90 _ => None,
91 }
92}
93
94/// The repository a source label names, by name, if it is one of
95/// `workspace`'s on this registry's host: `https://g1t.sh/acme/web`, with
96/// or without `.git` or a trailing slash.
97pub(crate) fn source_repo(label: &str, host: &str, workspace: &str) -> Option<String> {
98 let rest = label.trim().strip_prefix("https://")?;
99 let (at, path) = rest.split_once('/')?;
100 if !at.eq_ignore_ascii_case(host) && !at.eq_ignore_ascii_case(&format!("www.{host}")) {
101 return None;
102 }
103 let path = path.trim_end_matches('/');
104 let path = path.strip_suffix(".git").unwrap_or(path);
105 let mut parts = path.split('/');
106 let (owner, name) = (parts.next()?, parts.next()?);
107 if parts.next().is_some() || !owner.eq_ignore_ascii_case(workspace) || !g1t_contracts::is_valid_repo_name(name) {
108 return None;
109 }
110 Some(name.to_owned())
111}
112
113/// The source label of an image: its manifest's annotation, else its
114/// config's label.
115pub(crate) fn source_label(annotations: Option<&serde_json::Value>, config: Option<&serde_json::Value>) -> Option<String> {
116 const KEY: &str = "org.opencontainers.image.source";
117 annotations
118 .and_then(|annotations| annotations[KEY].as_str())
119 .or_else(|| config.and_then(|config| config["config"]["Labels"][KEY].as_str()))
120 .map(str::to_owned)
121}
122
123fn grants_of(rows: &[crate::db::AccessRow]) -> Vec<Grant> {
124 rows.iter()
125 .filter_map(|row| {
126 Some(Grant {
127 kind: GranteeKind::parse(&row.grantee_kind)?,
128 id: row.grantee_id.clone(),
129 role: PackageRole::parse(&row.role)?,
130 })
131 })
132 .collect()
133}
134
135fn actions_of(rows: &[crate::db::ActionsRow]) -> Vec<RepoAccess> {
136 rows.iter()
137 .filter_map(|row| Some(RepoAccess { name: row.repo_name.clone(), role: PackageRole::parse(&row.role)? }))
138 .collect()
139}
140
141impl Packages {
142 /// Whether `viewer` may `action` the package, reading its grants,
143 /// Actions access and the viewer's teams only when the decision
144 /// without them refuses.
145 pub(crate) async fn decide(&self, viewer: Option<&User>, target: &mut TargetOf, action: Action) -> Result<Decision> {
146 let first = access::decide(viewer, &target.view(), action);
147 if first.allowed || target.loaded || viewer.is_none() {
148 return Ok(first);
149 }
150 self.load(viewer, std::slice::from_mut(target)).await?;
151 Ok(access::decide(viewer, &target.view(), action))
152 }
153
154 /// A registry's decision on `action`, and whether the viewer may at
155 /// least pull the package (a refusal is then a 403, not a 404).
156 pub(crate) async fn check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<(Decision, bool)> {
157 let mut target = TargetOf::package(package);
158 let decision = self.decide(viewer, &mut target, action).await?;
159 if decision.allowed {
160 return Ok((decision, true));
161 }
162 let readable = action != Action::Pull && self.decide(viewer, &mut target, Action::Pull).await?.allowed;
163 Ok((decision, readable))
164 }
165
166 /// Everything `viewer` may do with the package, for the site.
167 pub(crate) async fn permissions(&self, viewer: Option<&User>, target: &mut TargetOf) -> Result<PackagePermissions> {
168 if viewer.is_some() && !target.loaded {
169 self.load(viewer, std::slice::from_mut(target)).await?;
170 }
171 Ok(access::permissions(viewer, &target.view()))
172 }
173
174 /// Whether `viewer` may `action` each package, reading what the
175 /// refused ones need in one query.
176 pub(crate) async fn may_all(&self, viewer: Option<&User>, packages: &[&PackageRow], action: Action) -> Result<Vec<bool>> {
177 let mut targets: Vec<TargetOf> = packages.iter().map(|package| TargetOf::package(package)).collect();
178 let mut may: Vec<bool> = targets.iter().map(|target| access::decide(viewer, &target.view(), action).allowed).collect();
179 if viewer.is_none() || may.iter().all(|allowed| *allowed) {
180 return Ok(may);
181 }
182 let refused: Vec<usize> = (0..targets.len()).filter(|at| !may[*at]).collect();
183 let mut again: Vec<TargetOf> = refused.iter().map(|at| std::mem::replace(&mut targets[*at], TargetOf::unmade("", "", None))).collect();
184 self.load(viewer, &mut again).await?;
185 for (at, target) in refused.into_iter().zip(again) {
186 may[at] = access::decide(viewer, &target.view(), action).allowed;
187 }
188 Ok(may)
189 }
190
191 /// Reads the grants and Actions access of packages that are made, and
192 /// which of their granted teams the person asking is in.
193 async fn load(&self, viewer: Option<&User>, targets: &mut [TargetOf]) -> Result<()> {
194 let ids: Vec<String> = targets.iter().filter_map(|target| target.package_id.clone()).collect();
195 if ids.is_empty() {
196 for target in targets.iter_mut() {
197 target.loaded = true;
198 }
199 return Ok(());
200 }
201 let access = self.db.access(&ids).await?;
202 let actions = self.db.actions_access(&ids).await?;
203 // The teams granted anything, by workspace and slug, once.
204 let mut teams: Vec<(String, String)> = Vec::new();
205 for target in targets.iter() {
206 for row in access.iter().filter(|row| Some(&row.package_id) == target.package_id.as_ref() && row.grantee_kind == "team") {
207 let name = (row.grantee_id.clone(), format!("{}/{}", target.workspace, row.grantee_name));
208 if !teams.contains(&name) {
209 teams.push(name);
210 }
211 }
212 }
213 let mine = match viewer.and_then(person_id) {
214 Some(person) if !teams.is_empty() => self.teams_with(&person, teams.into_iter().map(|(_, name)| name).collect()).await?,
215 _ => HashSet::new(),
216 };
217 for target in targets.iter_mut() {
218 let Some(id) = target.package_id.clone() else {
219 target.loaded = true;
220 continue;
221 };
222 let own: Vec<_> = access.iter().filter(|row| row.package_id == id).cloned().collect();
223 let repos: Vec<_> = actions.iter().filter(|row| row.package_id == id).cloned().collect();
224 target.grants = grants_of(&own);
225 target.actions = actions_of(&repos);
226 target.teams = target.grants.iter().filter(|grant| mine.contains(&grant.id)).map(|grant| grant.id.clone()).collect();
227 target.loaded = true;
228 }
229 Ok(())
230 }
231
232 /// Which of these teams (`workspace/slug`) the person is in, by id,
233 /// counting their child teams' people.
234 async fn teams_with(&self, person: &str, names: Vec<String>) -> Result<HashSet<String>> {
235 let resolved: Vec<ResolvedTeam> = g1t_kit::call(
236 &self.identity,
237 "resolve_teams",
238 &ResolveTeamsArgs { teams: names, repo_id: None, asker: None },
239 )
240 .await?;
241 Ok(resolved
242 .into_iter()
243 .filter(|team| team.members.iter().chain(&team.child_members).any(|member| member.id == person))
244 .map(|team| team.id)
245 .collect())
246 }
247
248 /// A package by name, deleted or not, with whether `viewer` may see it.
249 async fn package_any(&self, workspace: &str, ecosystem: Ecosystem, name: &str) -> Result<Option<PackageRow>> {
250 Ok(self
251 .db
252 .package(&workspace.to_lowercase(), ecosystem.as_str(), name)
253 .await?
254 .filter(|package| package.workspace_deleted_at.is_none()))
255 }
256
257 /// The package `viewer` may pull, not deleted.
258 async fn readable(&self, viewer: &Viewer, workspace: &str, ecosystem: Ecosystem, name: &str) -> Result<Option<(PackageRow, TargetOf)>> {
259 let Some(package) = self.package_any(workspace, ecosystem, name).await?.filter(|package| package.deleted_at.is_none()) else {
260 return Ok(None);
261 };
262 let mut target = TargetOf::package(&package);
263 if !self.decide(viewer.as_ref(), &mut target, Action::Pull).await?.allowed {
264 return Ok(None);
265 }
266 Ok(Some((package, target)))
267 }
268
269 pub(crate) async fn list_versions(&self, a: ListVersionsArgs) -> Result<Outcome<Vec<PackageVersion>>> {
270 let Some((package, mut target)) = self.readable(&a.viewer, &a.workspace, a.ecosystem, &a.name).await? else {
271 return Ok(not_found());
272 };
273 if a.deleted {
274 if !self.decide(a.viewer.as_ref(), &mut target, Action::Settings).await?.allowed {
275 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an admin of the package can see its deleted versions."));
276 }
277 let versions = self.db.deleted_versions(&package.id, DELETED_SHOWN).await?;
278 return Ok(Outcome::Ok(versions.into_iter().map(|version| version_of(&package, version, &[])).collect()));
279 }
280 let tags = self.db.tags(&package.id).await?;
281 let versions = self.db.versions(&package.id, crate::VERSIONS_SHOWN).await?;
282 Ok(Outcome::Ok(versions.into_iter().map(|version| version_of(&package, version, &tags)).collect()))
283 }
284
285 pub(crate) async fn get_version(&self, a: GetVersionArgs) -> Result<Outcome<PackageVersion>> {
286 let Some((package, _)) = self.readable(&a.viewer, &a.workspace, a.ecosystem, &a.name).await? else {
287 return Ok(not_found());
288 };
289 let Some(version) = self.db.find_version(&package.id, a.version.trim()).await? else {
290 return Ok(Outcome::fail(FailureCode::NotFound, "Version not found."));
291 };
292 let tags = self.db.tags(&package.id).await?;
293 Ok(Outcome::Ok(version_of(&package, version, &tags)))
294 }
295
296 /// Deletes a version: hidden at once, restorable for 30 days; its
297 /// version string cannot be published again until it is purged.
298 pub(crate) async fn remove_version_from(&self, package: &PackageRow, version: &VersionRow, caller: &Caller, surface: Option<Surface>) -> Result<()> {
299 let by = caller.actor.as_ref().map_or("", |actor| actor.actor.as_str());
300 self.db.soft_delete_version(&version.id, by, now_ms()).await?;
301 self.db.measure(&package.workspace).await?;
302 let event = g1t_contracts::events::PackageEvent {
303 version: Some(version.version.clone()),
304 digest: Some(version.digest.clone()),
305 ..self.event_of(package)
306 };
307 self.announce("package.version_deleted", package, event, caller).await;
308 self.audit_with(
309 caller,
310 "package.delete_version",
311 package,
312 Some(&self.version_path(package, version)),
313 surface,
314 Some(format!("Deleted the version; it can be restored for {RESTORE_DAYS} days")),
315 )
316 .await;
317 Ok(())
318 }
319
320 /// How audit entries name a version: an image (and a Composer version,
321 /// by its commit) by its digest, every other package by its version.
322 pub(crate) fn version_path(&self, package: &PackageRow, version: &VersionRow) -> String {
323 if package.ecosystem == "npm" {
324 format!("@{}/{}@{}", package.workspace, package.name, version.version)
325 } else if !matches!(package.ecosystem.as_str(), "container" | "composer") {
326 format!("{}/{}@{}", package.workspace, package.name, version.version)
327 } else {
328 format!("{}/{}@{}", package.workspace, package.name, version.digest)
329 }
330 }
331
332 pub(crate) async fn restore_version(&self, a: RestoreVersionArgs) -> Result<Outcome<PackageVersion>> {
333 let package = match self.for_change(&a.actor, &a.workspace, a.ecosystem, &a.name, Action::Delete).await? {
334 Outcome::Ok(package) => package,
335 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
336 };
337 let Some(version) = self.db.deleted_version(&package.id, a.version.trim()).await?.filter(|v| v.deleted_at.as_deref().is_some_and(|at| restorable(at, now_ms()))) else {
338 return Ok(Outcome::fail(
339 FailureCode::NotFound,
340 format!("No deleted version {} of {} can be restored: it was never deleted, or was purged.", a.version.trim(), package.name),
341 ));
342 };
343 let now = now_ms();
344 self.db.restore_version(&package.id, &version.id, now).await?;
345 self.db.measure(&package.workspace).await?;
346 let caller = Caller::of(Some(&a.actor));
347 self.audit_with(
348 &caller,
349 "package.restore_version",
350 &package,
351 Some(&self.version_path(&package, &version)),
352 a.surface,
353 Some("Restored the version".to_owned()),
354 )
355 .await;
356 let tags = self.db.tags(&package.id).await?;
357 let restored = VersionRow { deleted_at: None, deleted_by: None, ..version };
358 Ok(Outcome::Ok(version_of(&package, restored, &tags)))
359 }
360
361 pub(crate) async fn restore_package(&self, a: RestorePackageArgs) -> Result<Outcome<PackageSummary>> {
362 let Some(package) = self.package_any(&a.workspace, a.ecosystem, &a.name).await?.filter(|package| package.deleted_at.as_deref().is_some_and(|at| restorable(at, now_ms()))) else {
363 return Ok(Outcome::fail(
364 FailureCode::NotFound,
365 format!("There is no deleted package {} to restore: it was never deleted, or was purged.", a.name),
366 ));
367 };
368 match self.allowed(&a.actor, &package, Action::Delete).await? {
369 Outcome::Ok(()) => {}
370 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
371 }
372 self.db.restore_package(&package.id, now_ms()).await?;
373 self.db.measure(&package.workspace).await?;
374 let caller = Caller::of(Some(&a.actor));
375 self.audit_with(&caller, "package.restore", &package, None, a.surface, Some("Restored the package".to_owned())).await;
376 let Some(row) = self.listed(&package.workspace, a.ecosystem, &package.name).await? else {
377 return Ok(not_found());
378 };
379 Ok(Outcome::Ok(self.summary(&row)))
380 }
381
382 pub(crate) async fn deleted_packages(&self, a: DeletedPackagesArgs) -> Result<Outcome<Vec<PackageSummary>>> {
383 if a.viewer.is_none() {
384 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to see deleted packages."));
385 }
386 let rows = self.db.deleted_packages(&a.workspace.to_lowercase(), crate::LIST_LIMIT).await?;
387 let packages: Vec<&PackageRow> = rows.iter().map(|row| &row.package).collect();
388 let may = self.may_all(a.viewer.as_ref(), &packages, Action::Settings).await?;
389 Ok(Outcome::Ok(rows.iter().zip(may).filter(|(_, may)| *may).map(|(row, _)| self.summary(row)).collect()))
390 }
391
392 /// The package `viewer` administers, for its settings.
393 async fn administered(&self, viewer: &User, workspace: &str, ecosystem: Ecosystem, name: &str) -> Result<Outcome<PackageRow>> {
394 self.for_change(viewer, workspace, ecosystem, name, Action::Admin).await
395 }
396
397 async fn access_list(&self, package: &PackageRow) -> Result<Vec<PackageAccess>> {
398 Ok(self
399 .db
400 .access(std::slice::from_ref(&package.id))
401 .await?
402 .into_iter()
403 .filter_map(|row| {
404 let kind = GranteeKind::parse(&row.grantee_kind)?;
405 Some(PackageAccess {
406 name: match kind {
407 GranteeKind::Team => format!("{}/{}", package.workspace, row.grantee_name),
408 GranteeKind::User => row.grantee_name,
409 },
410 kind,
411 id: row.grantee_id,
412 role: PackageRole::parse(&row.role)?,
413 created_at: row.created_at,
414 })
415 })
416 .collect())
417 }
418
419 async fn actions_list(&self, package: &PackageRow) -> Result<Vec<ActionsAccess>> {
420 let mut list = Vec::new();
421 if let (Some(id), Some(name)) = (&package.repo_id, &package.repo_name) {
422 list.push(ActionsAccess {
423 repo_id: id.clone(),
424 repo: format!("{}/{name}", package.workspace),
425 role: PackageRole::Write,
426 linked: true,
427 created_at: None,
428 });
429 }
430 for row in self.db.actions_access(std::slice::from_ref(&package.id)).await? {
431 if Some(&row.repo_id) == package.repo_id.as_ref() {
432 continue;
433 }
434 let Some(role) = PackageRole::parse(&row.role) else { continue };
435 list.push(ActionsAccess {
436 repo_id: row.repo_id,
437 repo: format!("{}/{}", package.workspace, row.repo_name),
438 role,
439 linked: false,
440 created_at: Some(row.created_at),
441 });
442 }
443 Ok(list)
444 }
445
446 pub(crate) async fn package_settings(&self, a: PackageSettingsArgs) -> Result<Outcome<PackageSettings>> {
447 let Some(viewer) = a.viewer.as_ref() else {
448 return Ok(not_found());
449 };
450 let package = match self.for_change(viewer, &a.workspace, a.ecosystem, &a.name, Action::Settings).await? {
451 Outcome::Ok(package) => package,
452 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
453 };
454 let Some(row) = self.listed(&package.workspace, a.ecosystem, &package.name).await? else {
455 return Ok(not_found());
456 };
457 let mut target = TargetOf::package(&package);
458 let permissions = self.permissions(a.viewer.as_ref(), &mut target).await?;
459 let deleted_versions = if permissions.delete {
460 self.db
461 .deleted_versions(&package.id, DELETED_SHOWN)
462 .await?
463 .into_iter()
464 .map(|version| version_of(&package, version, &[]))
465 .collect()
466 } else {
467 Vec::new()
468 };
469 Ok(Outcome::Ok(PackageSettings {
470 package: self.summary(&row),
471 access: self.access_list(&package).await?,
472 actions_access: self.actions_list(&package).await?,
473 deleted_versions,
474 permissions,
475 }))
476 }
477
478 /// The person or team a change of access names: (kind, id, name kept).
479 async fn grantee(&self, package: &PackageRow, user: Option<&str>, team: Option<&str>) -> Result<Outcome<(GranteeKind, String, String)>> {
480 let clean = |text: &str| text.trim().trim_start_matches('@').to_lowercase();
481 match (user.map(clean).filter(|u| !u.is_empty()), team.map(clean).filter(|t| !t.is_empty())) {
482 (Some(username), None) => {
483 let found: Viewer = g1t_kit::call(&self.identity, "user_by_username", &UsernameArgs { username: username.clone() }).await?;
484 match found {
485 Some(person) if person.kind == PrincipalKind::User => Ok(Outcome::Ok((GranteeKind::User, person.id, person.username))),
486 _ => Ok(Outcome::fail(FailureCode::NotFound, format!("There is no one called {username} on g1t."))),
487 }
488 }
489 (None, Some(team)) => {
490 let slug = match team.split_once('/') {
491 Some((owner, slug)) if owner == package.workspace => slug.to_owned(),
492 Some(_) => {
493 return Ok(Outcome::fail(FailureCode::Invalid, format!("Only a team of {} can have a role on its packages.", package.workspace)));
494 }
495 None => team,
496 };
497 let resolved: Vec<ResolvedTeam> = g1t_kit::call(
498 &self.identity,
499 "resolve_teams",
500 &ResolveTeamsArgs { teams: vec![format!("{}/{slug}", package.workspace)], repo_id: None, asker: None },
501 )
502 .await?;
503 match resolved.into_iter().next() {
504 Some(found) => Ok(Outcome::Ok((GranteeKind::Team, found.id, found.slug))),
505 None => Ok(Outcome::fail(FailureCode::NotFound, format!("There is no team {}/{slug}.", package.workspace))),
506 }
507 }
508 _ => Ok(Outcome::fail(FailureCode::Invalid, "Name one person (user, a username) or one team (team, its slug).")),
509 }
510 }
511
512 pub(crate) async fn set_package_access(&self, a: SetPackageAccessArgs) -> Result<Outcome<Vec<PackageAccess>>> {
513 let package = match self.administered(&a.actor, &a.workspace, a.ecosystem, &a.name).await? {
514 Outcome::Ok(package) => package,
515 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
516 };
517 let (kind, id, name) = match self.grantee(&package, a.user.as_deref(), a.team.as_deref()).await? {
518 Outcome::Ok(found) => found,
519 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
520 };
521 let before = self
522 .db
523 .access(std::slice::from_ref(&package.id))
524 .await?
525 .into_iter()
526 .find(|row| row.grantee_kind == kind.as_str() && row.grantee_id == id)
527 .and_then(|row| PackageRole::parse(&row.role));
528 if before == Some(a.role) {
529 return Ok(Outcome::Ok(self.access_list(&package).await?));
530 }
531 self.db.set_access(&package.id, kind.as_str(), &id, &name, a.role.as_str(), &a.actor.username, now_ms()).await?;
532 let who = match kind {
533 GranteeKind::User => name.clone(),
534 GranteeKind::Team => format!("the team {}/{name}", package.workspace),
535 };
536 let (action, message) = match before {
537 None => ("package.access_added", format!("Gave {who} the {} role", a.role.label())),
538 Some(was) => ("package.access_role_changed", format!("Changed {who}'s role from {} to {}", was.label(), a.role.label())),
539 };
540 self.audit_with(&Caller::of(Some(&a.actor)), action, &package, None, a.surface, Some(message)).await;
541 Ok(Outcome::Ok(self.access_list(&package).await?))
542 }
543
544 pub(crate) async fn remove_package_access(&self, a: RemovePackageAccessArgs) -> Result<Outcome<Vec<PackageAccess>>> {
545 let package = match self.administered(&a.actor, &a.workspace, a.ecosystem, &a.name).await? {
546 Outcome::Ok(package) => package,
547 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
548 };
549 // Matched against what is kept, so someone renamed or a team
550 // since deleted can still be taken off.
551 let clean = |text: &str| text.trim().trim_start_matches('@').to_lowercase();
552 let (kind, wanted) = match (a.user.as_deref().map(clean), a.team.as_deref().map(clean)) {
553 (Some(user), None) if !user.is_empty() => (GranteeKind::User, user),
554 (None, Some(team)) if !team.is_empty() => {
555 let slug = team.rsplit_once('/').map_or(team.clone(), |(_, slug)| slug.to_owned());
556 (GranteeKind::Team, slug)
557 }
558 _ => return Ok(Outcome::fail(FailureCode::Invalid, "Name one person (user, a username) or one team (team, its slug).")),
559 };
560 let found = self
561 .db
562 .access(std::slice::from_ref(&package.id))
563 .await?
564 .into_iter()
565 .find(|row| row.grantee_kind == kind.as_str() && (row.grantee_name.eq_ignore_ascii_case(&wanted) || row.grantee_id == wanted));
566 let Some(found) = found else {
567 return Ok(Outcome::fail(FailureCode::NotFound, format!("{wanted} has no role on the package itself.")));
568 };
569 self.db.remove_access(&package.id, kind.as_str(), &found.grantee_id).await?;
570 let who = match kind {
571 GranteeKind::User => found.grantee_name.clone(),
572 GranteeKind::Team => format!("the team {}/{}", package.workspace, found.grantee_name),
573 };
574 self.audit_with(
575 &Caller::of(Some(&a.actor)),
576 "package.access_removed",
577 &package,
578 None,
579 a.surface,
580 Some(format!("Took {who}'s {} role away", PackageRole::parse(&found.role).map_or("", PackageRole::label))),
581 )
582 .await;
583 Ok(Outcome::Ok(self.access_list(&package).await?))
584 }
585
586 /// The repository of the package's workspace `repo` names (its name, or
587 /// `owner/name`).
588 async fn workspace_repo(&self, package: &PackageRow, repo: &str) -> Result<Outcome<g1t_contracts::repos::Repo>> {
589 let repo = repo.trim().trim_end_matches(".git");
590 let name = match repo.split_once('/') {
591 Some((owner, name)) if owner.eq_ignore_ascii_case(&package.workspace) => name,
592 Some(_) => {
593 return Ok(Outcome::fail(
594 FailureCode::Invalid,
595 format!("Only repositories of {} can be given access to its packages.", package.workspace),
596 ));
597 }
598 None => repo,
599 };
600 match self.repo_by_name(&package.workspace, name).await? {
601 Some(found) => Ok(Outcome::Ok(found)),
602 None => Ok(Outcome::fail(FailureCode::NotFound, format!("There is no repository {}/{name}.", package.workspace))),
603 }
604 }
605
606 pub(crate) async fn set_actions_access(&self, a: SetActionsAccessArgs) -> Result<Outcome<Vec<ActionsAccess>>> {
607 let package = match self.administered(&a.actor, &a.workspace, a.ecosystem, &a.name).await? {
608 Outcome::Ok(package) => package,
609 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
610 };
611 if a.role == PackageRole::Admin {
612 return Ok(Outcome::fail(FailureCode::Invalid, "A repository's workflows can have the read or write role on a package."));
613 }
614 let repo = match self.workspace_repo(&package, &a.repo).await? {
615 Outcome::Ok(repo) => repo,
616 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
617 };
618 if package.repo_id.as_deref() == Some(repo.id.as_str()) {
619 return Ok(Outcome::fail(
620 FailureCode::Invalid,
621 format!("{}/{} is the package's own repository: its workflows can always publish it.", repo.namespace, repo.name),
622 ));
623 }
624 let before = self
625 .db
626 .actions_access(std::slice::from_ref(&package.id))
627 .await?
628 .into_iter()
629 .find(|row| row.repo_id == repo.id)
630 .and_then(|row| PackageRole::parse(&row.role));
631 if before != Some(a.role) {
632 self.db.set_actions(&package.id, &repo.id, &repo.name, a.role.as_str(), &a.actor.username, now_ms()).await?;
633 let (action, message) = match before {
634 None => ("package.actions_access_added", format!("Let {}/{}'s workflows use it with the {} role", repo.namespace, repo.name, a.role.label())),
635 Some(was) => (
636 "package.actions_access_role_changed",
637 format!("Changed {}/{}'s workflows' role from {} to {}", repo.namespace, repo.name, was.label(), a.role.label()),
638 ),
639 };
640 self.audit_with(&Caller::of(Some(&a.actor)), action, &package, None, a.surface, Some(message)).await;
641 }
642 Ok(Outcome::Ok(self.actions_list(&package).await?))
643 }
644
645 pub(crate) async fn remove_actions_access(&self, a: RemoveActionsAccessArgs) -> Result<Outcome<Vec<ActionsAccess>>> {
646 let package = match self.administered(&a.actor, &a.workspace, a.ecosystem, &a.name).await? {
647 Outcome::Ok(package) => package,
648 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
649 };
650 let wanted = a.repo.trim().trim_end_matches(".git");
651 let wanted = wanted.rsplit_once('/').map_or(wanted, |(_, name)| name);
652 if package.repo_name.as_deref().is_some_and(|name| name.eq_ignore_ascii_case(wanted)) {
653 return Ok(Outcome::fail(
654 FailureCode::Invalid,
655 "The package's own repository keeps its access: unlink the package to take it away.",
656 ));
657 }
658 let Some(found) = self
659 .db
660 .actions_access(std::slice::from_ref(&package.id))
661 .await?
662 .into_iter()
663 .find(|row| row.repo_name.eq_ignore_ascii_case(wanted) || row.repo_id == wanted)
664 else {
665 return Ok(Outcome::fail(FailureCode::NotFound, format!("{}/{wanted} has no Actions access to the package.", package.workspace)));
666 };
667 self.db.remove_actions(&package.id, &found.repo_id).await?;
668 self.audit_with(
669 &Caller::of(Some(&a.actor)),
670 "package.actions_access_removed",
671 &package,
672 None,
673 a.surface,
674 Some(format!("Stopped {}/{}'s workflows using it", package.workspace, found.repo_name)),
675 )
676 .await;
677 Ok(Outcome::Ok(self.actions_list(&package).await?))
678 }
679
680 /// Removes for good what was deleted more than 30 days ago. Its files
681 /// go with the sweep a day later, as any unused file does.
682 pub(crate) async fn purge(&self, now: u64) -> Result<(u32, u32)> {
683 let before = purge_cutoff(now);
684 let caller = Caller::system();
685 let mut packages = 0;
686 let mut workspaces: HashSet<String> = HashSet::new();
687 for package in self.db.expired_packages(&before, PURGE_BATCH).await? {
688 self.db.delete_package(&package.id).await?;
689 self.audit_with(&caller, "package.purged", &package, None, Some(Surface::Registry), Some("Removed for good, 30 days after it was deleted".to_owned())).await;
690 workspaces.insert(package.workspace.clone());
691 packages += 1;
692 }
693 let mut versions = 0;
694 let mut owners: HashMap<String, Option<PackageRow>> = HashMap::new();
695 for version in self.db.expired_versions(&before, PURGE_BATCH).await? {
696 if !owners.contains_key(&version.package_id) {
697 let owner = self.db.package_by_id(&version.package_id).await?;
698 owners.insert(version.package_id.clone(), owner);
699 }
700 self.db.delete_version(&version.id).await?;
701 if let Some(Some(package)) = owners.get(&version.package_id) {
702 workspaces.insert(package.workspace.clone());
703 }
704 versions += 1;
705 }
706 for workspace in workspaces {
707 self.db.measure(&workspace).await?;
708 }
709 Ok((packages, versions))
710 }
711
712 /// Why nothing can be published to a hidden package: deleted (its name
713 /// is kept until the purge), or its workspace is.
714 pub(crate) fn hidden_refusal(package: &PackageRow) -> String {
715 match package.deleted_at.as_deref() {
716 Some(at) => format!(
717 "The package {} was deleted. Its name is kept until {} in case it is restored: an admin can restore it from the workspace's deleted packages, or publish under another name.",
718 package.name,
719 purge_at(at).map_or_else(|| "it is purged".to_owned(), |purge| purge[..10].to_owned())
720 ),
721 None => format!("The workspace {} is deleted; nothing can be published to it.", package.workspace),
722 }
723 }
724
725 /// Why `version` cannot be published to the package, if it is a deleted
726 /// version's (by version string or digest) that can still be restored.
727 pub(crate) async fn reserved_refusal(&self, package: &PackageRow, version: &str) -> Result<Option<String>> {
728 Ok(self.db.deleted_version(&package.id, version).await?.map(|deleted| {
729 let until = deleted.deleted_at.as_deref().and_then(purge_at).map_or_else(|| "it is purged".to_owned(), |at| at[..10].to_owned());
730 format!(
731 "{} {} was deleted, and a deleted version cannot be published again until {until}. Restore it from the package's settings, or publish another version.",
732 package.name, deleted.version
733 )
734 }))
735 }
736
737 /// Makes a package on its first push. One a workflow job makes that is
738 /// not linked to its repository lets that repository's workflows write
739 /// it, as Manage Actions access says.
740 #[allow(clippy::too_many_arguments)]
741 pub(crate) async fn make_package(
742 &self,
743 workspace: &str,
744 ecosystem: &str,
745 name: &str,
746 repo: Option<(&str, &str, bool)>,
747 caller: &Caller,
748 now: u64,
749 ) -> Result<PackageRow> {
750 let package = self
751 .db
752 .create_package(
753 &g1t_contracts::new_id("pkg", now),
754 workspace,
755 ecosystem,
756 name,
757 repo,
758 caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()),
759 now,
760 )
761 .await?;
762 let job_repo = caller
763 .viewer
764 .as_ref()
765 .and_then(|user| user.token.as_deref())
766 .filter(|token| token.job.is_some())
767 .and_then(|token| token.repo.clone());
768 if let Some(job) = job_repo
769 && let Some((owner, repo_name)) = job.split_once('/')
770 && owner.eq_ignore_ascii_case(workspace)
771 && package.repo_name.as_deref().is_none_or(|linked| !linked.eq_ignore_ascii_case(repo_name))
772 && let Some(found) = self.repo_by_name(workspace, repo_name).await?
773 {
774 self.db.set_actions(&package.id, &found.id, &found.name, PackageRole::Write.as_str(), &format!("{workspace}/{repo_name}"), now).await?;
775 }
776 Ok(package)
777 }
778
779 /// Links an image to the repository its source label names, on its
780 /// first push or while it is unlinked, when that is a repository of its
781 /// workspace and the pusher may publish its packages. Says whether it
782 /// linked it.
783 pub(crate) async fn link_by_source(&self, package: &PackageRow, label: &str, caller: &Caller, first: bool) -> Result<Option<PackageRow>> {
784 if !first && package.repo_id.is_some() {
785 return Ok(None);
786 }
787 let Some(name) = source_repo(label, &self.host, &package.workspace) else {
788 return Ok(None);
789 };
790 if package.repo_name.as_deref().is_some_and(|linked| linked.eq_ignore_ascii_case(&name)) {
791 return Ok(None);
792 }
793 let Some(repo) = self.repo_by_name(&package.workspace, &name).await? else {
794 return Ok(None);
795 };
796 // The pusher must be able to publish that repository's packages:
797 // Write on it, through a token that reaches it.
798 let mut target = TargetOf::unmade(&package.workspace, &package.name, Some((repo.id.clone(), repo.name.clone(), repo.is_private)));
799 if !self.decide(caller.viewer.as_ref(), &mut target, Action::Push).await?.allowed {
800 return Ok(None);
801 }
802 let visibility = if repo.is_private { "private" } else { "public" };
803 self.db.set_link(&package.id, Some((&repo.id, &repo.name)), visibility, now_ms()).await?;
804 self.audit_with(
805 caller,
806 "package.linked",
807 package,
808 None,
809 None,
810 Some(format!("Linked to {}/{} by its org.opencontainers.image.source label", package.workspace, repo.name)),
811 )
812 .await;
813 self.db.package_by_id(&package.id).await
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn a_source_label_names_a_repository_of_the_same_workspace() {
823 assert_eq!(source_repo("https://g1t.sh/acme/web", "g1t.sh", "acme").as_deref(), Some("web"));
824 assert_eq!(source_repo("https://g1t.sh/Acme/web.git", "g1t.sh", "acme").as_deref(), Some("web"));
825 assert_eq!(source_repo("https://g1t.sh/acme/web/", "g1t.sh", "acme").as_deref(), Some("web"));
826 assert_eq!(source_repo("https://g1t.sh/other/web", "g1t.sh", "acme"), None, "another workspace");
827 assert_eq!(source_repo("https://example.com/acme/web", "g1t.sh", "acme"), None, "another host");
828 assert_eq!(source_repo("http://g1t.sh/acme/web", "g1t.sh", "acme"), None);
829 assert_eq!(source_repo("https://g1t.sh/acme/web/tree/main", "g1t.sh", "acme"), None);
830 assert_eq!(source_repo("https://g1t.sh/acme", "g1t.sh", "acme"), None);
831 }
832
833 #[test]
834 fn the_label_is_read_from_annotations_then_the_config() {
835 let annotations = serde_json::json!({ "org.opencontainers.image.source": "https://g1t.sh/acme/a" });
836 let config = serde_json::json!({ "config": { "Labels": { "org.opencontainers.image.source": "https://g1t.sh/acme/b" } } });
837 assert_eq!(source_label(Some(&annotations), Some(&config)).as_deref(), Some("https://g1t.sh/acme/a"));
838 assert_eq!(source_label(None, Some(&config)).as_deref(), Some("https://g1t.sh/acme/b"));
839 assert_eq!(source_label(Some(&serde_json::json!({})), None), None);
840 }
841
842 #[test]
843 fn what_is_deleted_is_purged_thirty_days_on() {
844 assert_eq!(purge_at("2026-10-01T12:00:00.000Z").as_deref(), Some("2026-10-31T12:00:00.000Z"));
845 assert_eq!(purge_at("not a time"), None);
846 }
847}

This file's history is long; its oldest lines are credited to the oldest commit read.