Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add 'auto' approve feature to auth_cli #685

Merged
merged 9 commits into from
Oct 9, 2024
1 change: 1 addition & 0 deletions packages/at_cli_commons/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies:
version: ^3.0.2
logging: ^1.2.0
meta: ^1.11.0
path: ^1.9.0

dev_dependencies:
lints: ^3.0.0
Expand Down
2 changes: 2 additions & 0 deletions packages/at_onboarding_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
## 1.7.0
- feat: add `auto` command to the activate CLI
## 1.6.4
- build[deps]: upgrade: \
at_client to 3.2.2 | at_commons to 5.0.0 | at_lookup to 3.0.49 | at_utils to 3.0.19 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import 'package:at_onboarding_cli/at_onboarding_cli.dart';
import 'package:at_onboarding_cli/src/util/at_onboarding_exceptions.dart';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file is deprecated as all functionality is now in cli/auth_cli.dart

import 'package:at_utils/at_logger.dart';

@Deprecated('Use auth_cli')
Future<void> main(List<String> arguments) async {
int exitCode = await wrappedMain(arguments);
exit(exitCode);
}

@Deprecated('Use auth_cli')
Future<int> wrappedMain(List<String> arguments) async {
//defaults
String rootServer = 'root.atsign.org';
Expand Down Expand Up @@ -57,6 +59,7 @@ Future<int> wrappedMain(List<String> arguments) async {
return await activate(argResults);
}

@Deprecated('Use auth_cli')
Future<int> activate(ArgResults argResults,
{AtOnboardingService? atOnboardingService}) async {
stdout.writeln('[Information] Root server is ${argResults['rootServer']}');
Expand Down
167 changes: 158 additions & 9 deletions packages/at_onboarding_cli/lib/src/cli/auth_cli.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

Expand All @@ -20,10 +21,25 @@ final AtSignLogger logger = AtSignLogger(' CLI ');

final aca = AuthCliArgs();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now create ephemeral storage each time; we clean it up when we exit

Directory? storageDir;

void deleteStorage() {
// Windows will not let us delete files that are open
// so will will ignore this step and leave them in %localappdata%\Temp
if (!Platform.isWindows) {
if (storageDir != null) {
if (storageDir!.existsSync()) {
// stderr.writeln('${DateTime.now()} : Cleaning up temporary files');
storageDir!.deleteSync(recursive: true);
}
}
}
}

Future<int> main(List<String> arguments) async {
AtSignLogger.defaultLoggingHandler = AtSignLogger.stdErrLoggingHandler;
try {
return await _main(arguments);
return await wrappedMain(arguments);
} on ArgumentError catch (e) {
stderr.writeln('Invalid argument: ${e.message}');
aca.parser.printAllCommandsUsage();
Expand All @@ -32,10 +48,14 @@ Future<int> main(List<String> arguments) async {
stderr.writeln('Error: $e');
aca.parser.printAllCommandsUsage();
return 1;
} finally {
try {
deleteStorage();
} catch (_) {}
}
}

Future<int> _main(List<String> arguments) async {
Future<int> wrappedMain(List<String> arguments) async {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made public so that it can be called by the at_onboarding_cli_test in at_onboarding_cli_functional_tests

if (arguments.isEmpty) {
stderr.writeln('You must supply a command.');
aca.parser.printAllCommandsUsage(showSubCommandParams: false);
Expand Down Expand Up @@ -157,6 +177,10 @@ Future<int> _main(List<String> arguments) async {
await approve(
commandArgResults, await createAtClient(commandArgResults));

case AuthCliCommand.auto:
await autoApprove(
commandArgResults, await createAtClient(commandArgResults));

case AuthCliCommand.deny:
await deny(commandArgResults, await createAtClient(commandArgResults));

Expand Down Expand Up @@ -248,16 +272,25 @@ Future<int> status(ArgResults ar) async {
}

Future<AtClient> createAtClient(ArgResults ar) async {
String nameSpace = 'at_auth_cli';
if (storageDir != null) {
throw StateError('AtClient has already been created');
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a check to ensure we don't have a logic error elsewhere


String nameSpace = 'at_activate';
String atSign = AtUtils.fixAtSign(ar[AuthCliArgs.argNameAtSign]);
storageDir = standardAtClientStorageDir(
atSign: atSign,
progName: nameSpace,
uniqueID: '${DateTime.now().millisecondsSinceEpoch}',
);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the utility function newly added to at_cli_commons 1.2.0

CLIBase cliBase = CLIBase(
atSign: atSign,
atKeysFilePath: ar[AuthCliArgs.argNameAtKeys],
nameSpace: nameSpace,
rootDomain: ar[AuthCliArgs.argNameAtDirectoryFqdn],
homeDir: getHomeDirectory(),
storageDir: '${getHomeDirectory()}/.atsign/$nameSpace/$atSign/storage'
.replaceAll('/', Platform.pathSeparator),
storageDir: storageDir!.path,
verbose: ar[AuthCliArgs.argNameVerbose] || ar[AuthCliArgs.argNameDebug],
syncDisabled: true,
maxConnectAttempts: int.parse(
Expand Down Expand Up @@ -494,6 +527,9 @@ Future<void> interactive(ArgResults argResults, AtClient atClient) async {
case AuthCliCommand.approve:
await approve(commandArgResults, atClient);

case AuthCliCommand.auto:
await autoApprove(commandArgResults, atClient);

case AuthCliCommand.deny:
await deny(commandArgResults, atClient);

Expand Down Expand Up @@ -650,7 +686,8 @@ Future<Map> _fetchOrListAndFilter(
return enrollmentMap;
}

Future<void> approve(ArgResults ar, AtClient atClient) async {
Future<int> approve(ArgResults ar, AtClient atClient, {int? limit}) async {
int approved = 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an optional limit parameter and track and return an approved count so that we can also call this function from the autoApprove function when required

AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp;

Map toApprove = await _fetchOrListAndFilter(
Expand All @@ -663,13 +700,15 @@ Future<void> approve(ArgResults ar, AtClient atClient) async {

if (toApprove.isEmpty) {
stderr.writeln('No matching enrollment(s) found');
return;
return approved;
}

// Iterate through the requests, approve each one
for (String eId in toApprove.keys) {
Map er = toApprove[eId];
stdout.writeln('Approving enrollmentId $eId');
stdout.writeln('Approving enrollmentId $eId'
' with appName "${er['appName']}"'
' and deviceName "${er['deviceName']}"');
// Then make a 'decision' object using data from the enrollment request
EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved(
ApprovedRequestDecisionBuilder(
Expand All @@ -680,9 +719,119 @@ Future<void> approve(ArgResults ar, AtClient atClient) async {
final response = await atAuthBase
.atEnrollment(atClient.getCurrentAtSign()!)
.approve(decision, atLookup);
// 'enroll:approve:{"enrollmentId":"$enrollmentId"}'

stdout.writeln('Server response: $response');

approved++;

if (limit != null && approved >= limit) {
return approved;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were passed a limit parameter (e.g. by the autoApprove function) then let's respect it

}
return approved;
}

Future<int> autoApprove(ArgResults ar, AtClient atClient) async {
Copy link
Contributor Author

@gkc gkc Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New feature.

  • if requested, calls approve to approve any existing matching enrollment requests, and respecting the approval count limit while doing so
  • listen for new enrollment request notifications
  • when one is received, and it matches the supplied regex filter(s), then
    • approve it
    • check the approved count vs limit
    • if limit has been reached
      • complete the completer so the await completer.future completes
      • clean up the subscription

int approved = 0;
int limit = int.parse(ar[AuthCliArgs.argNameLimit]);
String? arx = ar[AuthCliArgs.argNameAppNameRegex];
String? drx = ar[AuthCliArgs.argNameDeviceNameRegex];
bool approveExisting = ar[AuthCliArgs.argNameAutoApproveExisting];

if (arx == null && drx == null) {
throw IllegalArgumentException(
'You must supply ${AuthCliArgs.argNameAppNameRegex}'
' and/or ${AuthCliArgs.argNameDeviceNameRegex}');
}

if (approveExisting) {
// Start by approving any which match and are already there
stdout.writeln('Approving any requests already there which are a match');
approved = await approve(ar, atClient, limit: limit);
stdout.writeln();
}

// If we've already approved our limit then we're done
if (approved >= limit) {
return approved;
}

Completer completer = Completer();

RegExp? appRegex;
RegExp? deviceRegex;
if (arx != null) {
appRegex = RegExp(arx);
}
if (drx != null) {
deviceRegex = RegExp(drx);
}

AtLookUp atLookup = atClient.getRemoteSecondary()!.atLookUp;

// listen for enrollment requests
stdout.writeln('Listening for new enrollment requests');

final stream = atClient.notificationService.subscribe(
regex: r'.*\.new\.enrollments\.__manage', shouldDecrypt: false);

final subscription = stream.listen((AtNotification n) async {
if (completer.isCompleted) {
return; // Don't handle any more if we're already done
}

String eId = n.key.substring(0, n.key.indexOf('.'));

final er = jsonDecode(n.value!);
stdout.writeln('Got enrollment request ID $eId'
' with appName "${er['appName']}"'
' and deviceName "${er['deviceName']}"');

// check the request matches our params
String appName = er['appName'];
String deviceName = er['deviceName'];
if ((appRegex?.hasMatch(appName) ?? true) &&
(deviceRegex?.hasMatch(deviceName) ?? true)) {
// request matched, let's approve it
stdout.writeln('Approving enrollment request'
' which matched the regex filters'
' (app: "$arx" and device: "$drx" respectively)');

EnrollmentRequestDecision decision = EnrollmentRequestDecision.approved(
ApprovedRequestDecisionBuilder(
enrollmentId: eId,
encryptedAPKAMSymmetricKey: er['encryptedAPKAMSymmetricKey']));

// Finally call approve method via an AtEnrollment object
final response = await atAuthBase
.atEnrollment(atClient.getCurrentAtSign()!)
.approve(decision, atLookup);
stdout.writeln('Approval successful.\n'
'\tResponse: $response');

// increment approved count
approved++;

// check approved vs limit
if (approved >= limit) {
// if reached limit, complete the future
stdout
.writeln('Approved $approved requests - limit was $limit - done.');
completer.complete();
}
} else {
stdout.writeln('Ignoring enrollment request'
' which does not match the regex filters'
' (app: "$arx" and device: "$drx" respectively)');
}
stdout.writeln();
});

// await future then cancel the subscription
await completer.future;
await subscription.cancel();

return approved;
}

Future<void> deny(ArgResults ar, AtClient atClient) async {
Expand Down
Loading