diff --git a/app/lib/admin/actions/actions.dart b/app/lib/admin/actions/actions.dart index 833ea85fa..b97439f4b 100644 --- a/app/lib/admin/actions/actions.dart +++ b/app/lib/admin/actions/actions.dart @@ -18,6 +18,7 @@ import 'moderation_case_list.dart'; import 'moderation_case_resolve.dart'; import 'moderation_case_update.dart'; import 'package_info.dart'; +import 'package_reservation_create.dart'; import 'package_version_info.dart'; import 'package_version_retraction.dart'; import 'publisher_block.dart'; @@ -98,6 +99,7 @@ final class AdminAction { moderationCaseResolve, moderationCaseUpdate, packageInfo, + packageReservationCreate, packageVersionInfo, packageVersionRetraction, publisherBlock, diff --git a/app/lib/admin/actions/package_reservation_create.dart b/app/lib/admin/actions/package_reservation_create.dart new file mode 100644 index 000000000..f06cce1d7 --- /dev/null +++ b/app/lib/admin/actions/package_reservation_create.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_dev/package/backend.dart'; +import 'package:pub_dev/package/models.dart'; +import 'package:pub_dev/shared/datastore.dart'; + +import 'actions.dart'; + +final packageReservationCreate = AdminAction( + name: 'package-reservation-create', + summary: 'Creates a ReservedPackage entity.', + description: ''' +Reserves a package name that can be claimed by the specified list of email addresses. + +The action can be re-run with the same package name. In such cases the previous +email list will be discarded, and the specified email list will be updated to the +existing ReservedPackage entity. + +When no emails are specified, the package will be reserved, but no user may be +able to claim it. +''', + options: { + 'package': 'The package name to be reserved.', + 'emails': 'The list of email addresses, separated by comma.' + }, + invoke: (options) async { + final package = options['package']; + InvalidInputException.check( + package != null && package.isNotEmpty, + '`package` must be given', + ); + final emails = options['emails']?.split(','); + + final p = await packageBackend.lookupPackage(package!); + if (p != null) { + throw InvalidInputException('Package `$package` exists.'); + } + final mp = await packageBackend.lookupModeratedPackage(package); + if (mp != null) { + throw InvalidInputException('ModeratedPackage `$package` exists.'); + } + + final entry = await withRetryTransaction(dbService, (tx) async { + final existing = await tx.lookupOrNull( + dbService.emptyKey.append(ReservedPackage, id: package)); + final entry = existing ?? ReservedPackage.init(package); + entry.emails = {...?emails}.toList(); + tx.insert(entry); + return entry; + }); + + return { + 'ReservedPackage': { + 'name': entry.name, + 'created': entry.created.toIso8601String(), + 'emails': entry.emails, + }, + }; + }, +); diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 3f8f4a200..8eb5cf9f2 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -183,6 +183,14 @@ class PackageBackend { return await db.lookupOrNull(packageKey); } + /// Looks up a reserved package by name. + /// + /// Returns `null` if the package doesn't exist. + Future lookupReservedPackage(String packageName) async { + final packageKey = db.emptyKey.append(ReservedPackage, id: packageName); + return await db.lookupOrNull(packageKey); + } + /// Looks up a package by name. Future> lookupPackages(Iterable packageNames) async { return (await db.lookup(packageNames @@ -1014,10 +1022,22 @@ class PackageBackend { required String name, required AuthenticatedAgent agent, }) async { - final isGoogleComUser = - agent is AuthenticatedUser && agent.user.email!.endsWith('@google.com'); - final isReservedName = matchesReservedPackageName(name); - final isExempted = isGoogleComUser && isReservedName; + final reservedPackage = await lookupReservedPackage(name); + + bool isAllowedUser = false; + if (agent is AuthenticatedUser) { + final email = agent.user.email; + if (reservedPackage != null) { + final reservedEmails = reservedPackage.emails; + isAllowedUser = email != null && reservedEmails.contains(email); + } else { + isAllowedUser = email != null && email.endsWith('@google.com'); + } + } + + final isReservedName = + reservedPackage != null || matchesReservedPackageName(name); + final isExempted = isReservedName && isAllowedUser; final conflictingName = await nameTracker.accept(name); if (conflictingName != null && !isExempted) { @@ -1039,8 +1059,8 @@ class PackageBackend { throw PackageRejectedException(newNameIssues.first.message); } - // reserved package names for the Dart team - if (isReservedName && !isGoogleComUser) { + // reserved package names for the Dart team or allowlisted users + if (isReservedName && !isAllowedUser) { throw PackageRejectedException.nameReserved(name); } } @@ -1125,6 +1145,14 @@ class PackageBackend { throw PackageRejectedException.nameReserved(newVersion.package); } + if (isNew) { + final reservedPackage = await tx.lookupOrNull( + db.emptyKey.append(ReservedPackage, id: newVersion.package)); + if (reservedPackage != null) { + tx.delete(reservedPackage.key); + } + } + // If the version already exists, we fail. if (version != null) { _logger.info('Version ${version.version} of package ' diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index 9cf1165d3..89c10e598 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -875,6 +875,28 @@ class ModeratedPackage extends db.ExpandoModel { List? versions; } +/// Entity representing a reserved package: the name is available only +/// for a subset of the users (`@google.com` + list of [emails]). +@db.Kind(name: 'ReservedPackage', idType: db.IdType.String) +class ReservedPackage extends db.ExpandoModel { + @db.StringProperty(required: true) + String? name; + + @db.DateTimeProperty() + late DateTime created; + + /// List of email addresses that are allowed to claim this package name. + /// This is on top of the `@google.com` email addresses. + @db.StringListProperty() + List emails = []; + + ReservedPackage(); + ReservedPackage.init(this.name) { + id = name; + created = clock.now().toUtc(); + } +} + /// An identifier to point to a specific [package] and [version]. class QualifiedVersionKey { final String? package; diff --git a/app/test/admin/package_reservation_test.dart b/app/test/admin/package_reservation_test.dart new file mode 100644 index 000000000..aa1894771 --- /dev/null +++ b/app/test/admin/package_reservation_test.dart @@ -0,0 +1,101 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:_pub_shared/data/admin_api.dart'; +import 'package:clock/clock.dart'; +import 'package:pub_dev/package/backend.dart'; +import 'package:pub_dev/package/models.dart'; +import 'package:pub_dev/shared/datastore.dart'; +import 'package:test/test.dart'; + +import '../package/backend_test_utils.dart'; +import '../shared/handlers_test_utils.dart'; +import '../shared/test_models.dart'; +import '../shared/test_services.dart'; + +void main() { + group('Reserve package', () { + Future _reserve( + String package, { + List? emails, + }) async { + final api = createPubApiClient(authToken: siteAdminToken); + await api.adminInvokeAction( + 'package-reservation-create', + AdminInvokeActionArguments(arguments: { + 'package': package, + if (emails != null) 'emails': emails.join(','), + }), + ); + } + + testWithProfile('cannot reserve existing package', fn: () async { + await expectApiException( + _reserve('oxygen'), + code: 'InvalidInput', + status: 400, + message: 'Package `oxygen` exists.', + ); + }); + + testWithProfile('cannot reserve ModeratedPackage', fn: () async { + await dbService.commit(inserts: [ + ModeratedPackage() + ..id = 'pkg' + ..name = 'pkg' + ..moderated = clock.now() + ..uploaders = [] + ..versions = ['1.0.0'] + ]); + await expectApiException( + _reserve('pkg'), + code: 'InvalidInput', + status: 400, + message: 'ModeratedPackage `pkg` exists.', + ); + }); + + testWithProfile('prevents non-whitelisted publishing', fn: () async { + await _reserve('pkg'); + + final pubspecContent = generatePubspecYaml('pkg', '1.0.0'); + final bytes = await packageArchiveBytes(pubspecContent: pubspecContent); + await expectApiException( + createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes(bytes), + code: 'PackageRejected', + status: 400, + message: 'Package name pkg is reserved.', + ); + }); + + testWithProfile('allows whitelisted publishing', fn: () async { + await _reserve('pkg'); + // update email addresses in a second request + await _reserve('pkg', emails: ['admin@pub.dev']); + + final pubspecContent = generatePubspecYaml('pkg', '1.0.0'); + final bytes = await packageArchiveBytes(pubspecContent: pubspecContent); + await createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes(bytes); + + final rp = await packageBackend.lookupReservedPackage('pkg'); + expect(rp, isNull); + }); + + testWithProfile('no longer allows Dart-team exemption', fn: () async { + await _reserve('pkg'); + + final pubspecContent = generatePubspecYaml('pkg', '1.0.0'); + final bytes = await packageArchiveBytes(pubspecContent: pubspecContent); + await expectApiException( + createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes(bytes), + code: 'PackageRejected', + status: 400, + message: 'Package name pkg is reserved.', + ); + }); + }); +}