Skip to content

Commit

Permalink
ReservedPackage + admin action. (#8105)
Browse files Browse the repository at this point in the history
  • Loading branch information
isoos authored Oct 7, 2024
1 parent 7e8f0b1 commit 91d82f8
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 6 deletions.
2 changes: 2 additions & 0 deletions app/lib/admin/actions/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -98,6 +99,7 @@ final class AdminAction {
moderationCaseResolve,
moderationCaseUpdate,
packageInfo,
packageReservationCreate,
packageVersionInfo,
packageVersionRetraction,
publisherBlock,
Expand Down
62 changes: 62 additions & 0 deletions app/lib/admin/actions/package_reservation_create.dart
Original file line number Diff line number Diff line change
@@ -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<ReservedPackage>(
dbService.emptyKey.append(ReservedPackage, id: package));
final entry = existing ?? ReservedPackage.init(package);
entry.emails = <String>{...?emails}.toList();
tx.insert(entry);
return entry;
});

return {
'ReservedPackage': {
'name': entry.name,
'created': entry.created.toIso8601String(),
'emails': entry.emails,
},
};
},
);
40 changes: 34 additions & 6 deletions app/lib/package/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ class PackageBackend {
return await db.lookupOrNull<ModeratedPackage>(packageKey);
}

/// Looks up a reserved package by name.
///
/// Returns `null` if the package doesn't exist.
Future<ReservedPackage?> lookupReservedPackage(String packageName) async {
final packageKey = db.emptyKey.append(ReservedPackage, id: packageName);
return await db.lookupOrNull<ReservedPackage>(packageKey);
}

/// Looks up a package by name.
Future<List<Package>> lookupPackages(Iterable<String> packageNames) async {
return (await db.lookup(packageNames
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -1125,6 +1145,14 @@ class PackageBackend {
throw PackageRejectedException.nameReserved(newVersion.package);
}

if (isNew) {
final reservedPackage = await tx.lookupOrNull<ReservedPackage>(
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 '
Expand Down
22 changes: 22 additions & 0 deletions app/lib/package/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,28 @@ class ModeratedPackage extends db.ExpandoModel<String> {
List<String>? 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<String> {
@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<String> emails = <String>[];

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;
Expand Down
101 changes: 101 additions & 0 deletions app/test/admin/package_reservation_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _reserve(
String package, {
List<String>? 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 = <String>[]
..versions = <String>['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.',
);
});
});
}

0 comments on commit 91d82f8

Please sign in to comment.