From e019e608fbc56ff59eb738d165a2f0bfcbb15c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simen=20Heggest=C3=B8yl?= Date: Wed, 6 Mar 2024 12:41:26 +0100 Subject: [PATCH 1/3] Add support for ID-porten clients Rename the `okdata pubreg` family of commands to `okdata pubs` (for "public services") to reflect that they now don't only handle Maskinporten clients, but also ID-porten clients. --- CHANGELOG.md | 3 + README.md | 2 +- doc/img/pubreg-wizard.png | Bin 18488 -> 0 bytes doc/img/pubs-wizard.png | Bin 0 -> 26122 bytes doc/pubreg.md | 31 +++--- okdata/cli/__main__.py | 4 +- okdata/cli/command.py | 4 +- okdata/cli/commands/datasets/questions.py | 13 +-- okdata/cli/commands/pubreg/__init__.py | 4 +- okdata/cli/commands/pubreg/client.py | 33 +++++- okdata/cli/commands/pubreg/pubreg.py | 98 ++++++++++------- okdata/cli/commands/pubreg/questions.py | 101 ++++++++++++++---- okdata/cli/commands/pubreg/wizards.py | 43 +++++--- .../cli/commands/{datasets => }/validators.py | 52 ++++++++- okdata/cli/commands/wizard.py | 8 ++ .../{datasets => }/validators_test.py | 93 +++++++++++++++- .../questions_tests.py => wizard_test.py} | 2 +- 17 files changed, 376 insertions(+), 115 deletions(-) delete mode 100644 doc/img/pubreg-wizard.png create mode 100644 doc/img/pubs-wizard.png rename okdata/cli/commands/{datasets => }/validators.py (72%) rename tests/origocli/commands/{datasets => }/validators_test.py (64%) rename tests/origocli/commands/{datasets/questions_tests.py => wizard_test.py} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1800118..6118f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## ?.?.? - Unreleased +* The `okdata pubreg` family of commands has been renamed to `okdata pubs` (for + "public services") to reflect that they now don't only handle Maskinporten + clients, but also ID-porten clients. * Added support for creating CSV->Delta dataset pipelines. * Added a shorthand for granting a user all permissions on a resource by not specifying the `scope` parameter to the `permissions add` command, similar to diff --git a/README.md b/README.md index 1ac611e..73f8956 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Okdata CLI provides a unified interface to data services for Oslo Origo teams. * [Install](doc/install.md) * [Configuration](doc/configuration.md) -* [Public registers](doc/pubreg.md) +* [Public services](doc/pubreg.md) * [Teams](doc/teams.md) * [Datasets](doc/datasets.md) * [Permissions](doc/permissions.md) diff --git a/doc/img/pubreg-wizard.png b/doc/img/pubreg-wizard.png deleted file mode 100644 index 064ad2c1808da65a4a35aaaf4f9d9cd90dbba3d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18488 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 z 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 literal 0 HcmV?d00001 diff --git a/doc/pubreg.md b/doc/pubreg.md index cbca49e..634561c 100644 --- a/doc/pubreg.md +++ b/doc/pubreg.md @@ -1,11 +1,12 @@ -# Public registers +# Public services -Commands related to accessing public registers are grouped under the `pubreg` -command prefix. Run the following command to see a list of all available -`pubreg` commands: +Commands related to accessing public services are grouped under the `pubs` +command prefix. Currently available public services are *Maskinporten* and +*ID-porten*. Run the following command to see a list of all available `pubs` +commands: ```sh -okdata pubreg -h +okdata pubs -h ``` Contents: @@ -19,7 +20,7 @@ Contents: * [Deleting a client key](#deleting-a-client-key) * [Viewing a client's audit log](#viewing-a-clients-audit-log) -## Prerequisites +## Prerequisites for accessing public registries Make sure you've got the right legal basis before accessing public registry data. Read more about this in our [guidelines for accessing public @@ -28,22 +29,22 @@ registers](https://github.com/oslokommune/dataplattform/blob/master/origo/regist ## Creating a client The following command launches a step-by-step wizard to create a new client for -accessing a public register: +accessing a public service: ```sh -okdata pubreg create-client +okdata pubs create-client ``` Example values from the wizard: -![Example values from the wizard](img/pubreg-wizard.png) +![Example values from the wizard](img/pubs-wizard.png) ## Listing clients Use the following command to display a table of all of your clients: ```sh -okdata pubreg list-clients +okdata pubs list-clients ``` ## Deleting a client @@ -52,7 +53,7 @@ When a client is no longer needed, it should be deleted by using the following command: ```sh -okdata pubreg delete-client +okdata pubs delete-client ``` Clients with active keys cannot be deleted. In such cases, their keys have to be @@ -73,7 +74,7 @@ The following command can be used to launch a step-by-step wizard to create a new client key: ```sh -okdata pubreg create-key +okdata pubs create-key ``` Each client can hold a maximum of five keys. After reaching that limit, old keys @@ -127,7 +128,7 @@ Use the following command to display a table of all the keys registered on one or all of your clients: ```sh -okdata pubreg list-keys +okdata pubs list-keys ``` ## Deleting a client key @@ -136,7 +137,7 @@ When a key is no longer needed, it should be deleted by using the following command: ```sh -okdata pubreg delete-key +okdata pubs delete-key ``` Note that key deletion is irreversible. @@ -146,7 +147,7 @@ Note that key deletion is irreversible. To view the audit log for a client, use the following command: ```sh -okdata pubreg audit-log +okdata pubs audit-log ``` Audit logs contain the history of events related to clients, including their diff --git a/okdata/cli/__main__.py b/okdata/cli/__main__.py index d1e3430..0274f3b 100644 --- a/okdata/cli/__main__.py +++ b/okdata/cli/__main__.py @@ -9,7 +9,7 @@ from okdata.cli.command import BaseCommand from okdata.cli.commands.datasets import DatasetsCommand from okdata.cli.commands.permissions import PermissionsCommand -from okdata.cli.commands.pubreg import PubregCommand +from okdata.cli.commands.pubreg import PubsCommand from okdata.cli.commands.status import StatusCommand from okdata.cli.commands.teams.teams import TeamsCommand @@ -69,7 +69,7 @@ def get_command_class(argv): commands = { "datasets": DatasetsCommand, "permissions": PermissionsCommand, - "pubreg": PubregCommand, + "pubs": PubsCommand, "status": StatusCommand, "teams": TeamsCommand, } diff --git a/okdata/cli/command.py b/okdata/cli/command.py index 9730a66..a76fc49 100644 --- a/okdata/cli/command.py +++ b/okdata/cli/command.py @@ -21,7 +21,7 @@ class BaseCommand: usage: okdata datasets [options] okdata permissions [options] - okdata pubreg [options] + okdata pubs [options] okdata status [options] okdata teams [options] okdata -h | --help @@ -29,7 +29,7 @@ class BaseCommand: Commands available: datasets permissions - pubreg + pubs status teams diff --git a/okdata/cli/commands/datasets/questions.py b/okdata/cli/commands/datasets/questions.py index d635c30..9e6e3e3 100644 --- a/okdata/cli/commands/datasets/questions.py +++ b/okdata/cli/commands/datasets/questions.py @@ -1,8 +1,6 @@ -import csv - from questionary import Choice -from okdata.cli.commands.datasets.validators import ( +from okdata.cli.commands.validators import ( KeywordValidator, PhoneValidator, SimpleEmailValidator, @@ -11,7 +9,7 @@ StandardsValidator, TitleValidator, ) -from okdata.cli.commands.wizard import required_style +from okdata.cli.commands.wizard import filter_comma_separated, required_style pipeline_choices = { "file": [ @@ -31,13 +29,6 @@ ] -def filter_comma_separated(value): - values = next( - csv.reader([value], delimiter=",", escapechar="\\", skipinitialspace=True) - ) - return [x.strip() for x in values if x] - - def qs_create(include_extra_metadata=True): return [ { diff --git a/okdata/cli/commands/pubreg/__init__.py b/okdata/cli/commands/pubreg/__init__.py index 2a6f771..fbbac41 100644 --- a/okdata/cli/commands/pubreg/__init__.py +++ b/okdata/cli/commands/pubreg/__init__.py @@ -1,3 +1,3 @@ -from okdata.cli.commands.pubreg.pubreg import PubregCommand +from okdata.cli.commands.pubreg.pubreg import PubsCommand -__all__ = ["PubregCommand"] +__all__ = ["PubsCommand"] diff --git a/okdata/cli/commands/pubreg/client.py b/okdata/cli/commands/pubreg/client.py index a1a5e88..6a02ea5 100644 --- a/okdata/cli/commands/pubreg/client.py +++ b/okdata/cli/commands/pubreg/client.py @@ -5,23 +5,48 @@ log = logging.getLogger() -class PubregClient(SDK): +class PubsClient(SDK): def __init__(self, config=None, auth=None, env=None): - self.__name__ = "pubreg" + self.__name__ = "pubs" super().__init__(config, auth, env) self.api_url = "https://api.data{}.oslo.systems/maskinporten".format( "-dev" if self.config.config["env"] == "dev" else "" ) - def create_client(self, team_id, provider, integration, scopes, env): + def create_maskinporten_client(self, team_id, provider, integration, scopes, env): data = { + "client_type": "maskinporten", "team_id": team_id, "provider": provider, "integration": integration, "scopes": scopes, "env": env, } - log.info(f"Creating client with payload: {data}") + log.info(f"Creating Maskinporten client with payload: {data}") + return self.post(f"{self.api_url}/clients", data=data).json() + + def create_idporten_client( + self, + team_id, + integration, + client_uri, + frontchannel_logout_uri, + redirect_uris, + post_logout_redirect_uris, + env, + ): + data = { + "client_type": "idporten", + "team_id": team_id, + "provider": "idporten", + "integration": integration, + "client_uri": client_uri, + "frontchannel_logout_uri": frontchannel_logout_uri, + "redirect_uris": redirect_uris, + "post_logout_redirect_uris": post_logout_redirect_uris, + "env": env, + } + log.info(f"Creating ID-porten client with payload: {data}") return self.post(f"{self.api_url}/clients", data=data).json() def get_clients(self, env): diff --git a/okdata/cli/commands/pubreg/pubreg.py b/okdata/cli/commands/pubreg/pubreg.py index 37addc0..789c96d 100644 --- a/okdata/cli/commands/pubreg/pubreg.py +++ b/okdata/cli/commands/pubreg/pubreg.py @@ -6,8 +6,13 @@ from okdata.cli import MAINTAINER from okdata.cli.command import BASE_COMMAND_OPTIONS, BaseCommand -from okdata.cli.commands.pubreg.client import PubregClient -from okdata.cli.commands.pubreg.questions import NoClientsError, NoKeysError +from okdata.cli.commands.pubreg.client import PubsClient +from okdata.cli.commands.pubreg.questions import ( + NoClientsError, + NoKeysError, + client_types, + providers, +) from okdata.cli.commands.pubreg.wizards import ( audit_log_wizard, create_client_wizard, @@ -21,33 +26,33 @@ from okdata.cli.output import create_output -class PubregCommand(BaseCommand): - __doc__ = f"""Oslo :: Public registers +class PubsCommand(BaseCommand): + __doc__ = f"""Oslo :: Public services Usage: - okdata pubreg create-client [options] - okdata pubreg list-clients [options] - okdata pubreg delete-client [options] - okdata pubreg create-key [options] - okdata pubreg list-keys [options] - okdata pubreg delete-key [options] - okdata pubreg audit-log [options] + okdata pubs create-client [options] + okdata pubs list-clients [options] + okdata pubs delete-client [options] + okdata pubs create-key [options] + okdata pubs list-keys [options] + okdata pubs delete-key [options] + okdata pubs audit-log [options] Examples: - okdata pubreg create-client - okdata pubreg list-clients - okdata pubreg delete-client - okdata pubreg create-key - okdata pubreg list-keys - okdata pubreg delete-key - okdata pubreg audit-log + okdata pubs create-client + okdata pubs list-clients + okdata pubs delete-client + okdata pubs create-key + okdata pubs list-keys + okdata pubs delete-key + okdata pubs audit-log Options:{BASE_COMMAND_OPTIONS} """ def __init__(self): super().__init__() - self.pubreg_client = PubregClient(env=self.opt("env")) + self.pubs_client = PubsClient(env=self.opt("env")) self.team_client = TeamClient(env=self.opt("env")) def handler(self): @@ -77,40 +82,55 @@ def create_client(self): return team_id = config["team_id"] + client_type_id = config["client_type_id"] provider_id = config["provider_id"] - provider_name = config["provider_name"] integration = config["integration"] scopes = config["scopes"] env = config["env"] self.confirm_to_continue( - f"Will create a new client for {provider_name} in {env} with " - f"scopes {scopes}." + "Will create a new {} client{} in {}{}.".format( + client_types[client_type_id], + f" for {providers[provider_id]}" if provider_id else "", + env, + f" with scopes {scopes}" if scopes else "", + ) ) self.print("Creating client...") - response = self.pubreg_client.create_client( - team_id, provider_id, integration, scopes, env - ) + if client_type_id == "maskinporten": + response = self.pubs_client.create_maskinporten_client( + team_id, provider_id, integration, scopes, env + ) + else: + response = self.pubs_client.create_idporten_client( + team_id, + integration, + config["client_uri"], + config["frontchannel_logout_uri"], + config["redirect_uris"], + config["post_logout_redirect_uris"], + env, + ) client_name = response["client_name"] self.print( f""" Done! Created a new client '{client_name}'. You may now go ahead and create a key for it by running: - okdata pubreg create-key""" + okdata pubs create-key""" ) def list_clients(self): config = list_clients_wizard() env = config["env"] - clients = self.pubreg_client.get_clients(env) + clients = self.pubs_client.get_clients(env) out = create_output(self.opt("format"), "pubreg_clients_config.json") out.add_rows(sorted(clients, key=itemgetter("client_name"))) self.print(f"Clients in {env}:", out) def delete_client(self): - choices = delete_client_wizard(self.pubreg_client) + choices = delete_client_wizard(self.pubs_client) env = choices["env"] client_id = choices["client_id"] client_name = choices["client_name"] @@ -134,7 +154,7 @@ def delete_client(self): ) self.print(f"Deleting client '{client_name}' [{env}]...") - res = self.pubreg_client.delete_client(env, client_id, aws_account, aws_region) + res = self.pubs_client.delete_client(env, client_id, aws_account, aws_region) deleted_ssm_params = res["deleted_ssm_params"] self.print("\nDone! The client is deleted and will no longer work.") @@ -205,11 +225,11 @@ def _handle_new_key_local(self, key, client_name, env): def create_client_key(self): try: - config = create_key_wizard(self.pubreg_client) + config = create_key_wizard(self.pubs_client) except NoClientsError: self.print( "No clients in the given environment yet!\n\n" - " Create one with `okdata pubreg create-client`.\n" + " Create one with `okdata pubs create-client`.\n" ) return @@ -247,7 +267,7 @@ def create_client_key(self): self.print("Creating key for '{}' [{}]...".format(client_name, env)) - key = self.pubreg_client.create_key( + key = self.pubs_client.create_key( env, client_id, aws_account, aws_region, enable_auto_rotate ) @@ -262,7 +282,7 @@ def create_client_key(self): ) def _list_client_keys_single(self, client_id, client_name, env): - keys = self.pubreg_client.get_keys(env, client_id) + keys = self.pubs_client.get_keys(env, client_id) out = create_output( self.opt("format"), "pubreg_single_client_keys_config.json", @@ -275,7 +295,7 @@ def _list_client_keys_multiple(self, clients, env): keys = [ {**key, "client_name": client["name"]} for client in clients - for key in self.pubreg_client.get_keys(env, client["id"]) + for key in self.pubs_client.get_keys(env, client["id"]) ] out = create_output( self.opt("format"), @@ -285,7 +305,7 @@ def _list_client_keys_multiple(self, clients, env): self.print(f"All client keys [{env}]:", out) def list_client_keys(self): - choices = list_keys_wizard(self.pubreg_client) + choices = list_keys_wizard(self.pubs_client) env = choices["env"] if "clients" in choices: @@ -297,7 +317,7 @@ def list_client_keys(self): def delete_client_key(self): try: - choices = delete_key_wizard(self.pubreg_client) + choices = delete_key_wizard(self.pubs_client) except NoKeysError: self.print("The selected client doesn't have any keys.") return @@ -312,16 +332,16 @@ def delete_client_key(self): ) self.print(f"Deleting key '{key_id}' from '{client_name}' [{env}]...") - self.pubreg_client.delete_key(env, client_id, key_id) + self.pubs_client.delete_key(env, client_id, key_id) self.print("Done! The key is deleted and will no longer work.") def audit_log(self): - choices = audit_log_wizard(self.pubreg_client) + choices = audit_log_wizard(self.pubs_client) env = choices["env"] client_id = choices["client_id"] client_name = choices["client_name"] - audit_log = self.pubreg_client.get_audit_log(env, client_id) + audit_log = self.pubs_client.get_audit_log(env, client_id) out = create_output(self.opt("format"), "pubreg_audit_log_config.json") out.add_rows(sorted(audit_log, key=itemgetter("timestamp"))) diff --git a/okdata/cli/commands/pubreg/questions.py b/okdata/cli/commands/pubreg/questions.py index a2afb11..becf91e 100644 --- a/okdata/cli/commands/pubreg/questions.py +++ b/okdata/cli/commands/pubreg/questions.py @@ -1,11 +1,21 @@ -import re from operator import itemgetter from questionary import Choice -from okdata.cli.commands.wizard import required_style +from okdata.cli.commands.validators import ( + AWSAccountValidator, + IntegrationValidator, + URIListValidator, + URIValidator, +) +from okdata.cli.commands.wizard import filter_comma_separated, required_style + +client_types = { + "idporten": "ID-porten", + "maskinporten": "Maskinporten", +} -_providers = { +providers = { "freg": "Folkeregisteret", "krr": "Kontaktregisteret", "skatt": "Skatteetaten", @@ -62,18 +72,29 @@ def q_env(): **required_style, "type": "select", "name": "env", - "message": "Maskinporten environment", + "message": "Environment", "choices": _environments, } +def q_client_type(): + return { + **required_style, + "type": "select", + "name": "client_type_id", + "message": "Client type", + "choices": [Choice(pname, pid) for pid, pname in client_types.items()], + } + + def q_provider(): return { **required_style, "type": "select", "name": "provider_id", "message": "Provider", - "choices": [Choice(pname, pid) for pid, pname in _providers.items()], + "choices": [Choice(pname, pid) for pid, pname in providers.items()], + "when": lambda x: x.get("client_type_id") == "maskinporten", } @@ -83,19 +104,13 @@ def q_scopes(): "type": "checkbox", "name": "scopes", "message": "Scopes", - "choices": lambda x: _scopes[x["provider_id"]], + "choices": lambda x: _scopes[x["provider_id"]] if "provider_id" in x else [], "validate": (lambda choices: bool(choices) or "Select at least one scope"), + "when": lambda x: x.get("client_type_id") == "maskinporten", } def q_integration(): - def _validate_integration(text): - if len(text) > 30: - return "Too long!" - if not re.fullmatch("[0-9a-z-]+", text): - return 'Only lowercase letters, numbers and "-", please' - return True - return { **required_style, "type": "text", @@ -104,13 +119,59 @@ def _validate_integration(text): "instruction": ( "(identifying in which system or case this client will be used)" ), - "validate": _validate_integration, + "validate": IntegrationValidator, } -def q_client(pubreg_client, allow_all=False): +def q_redirect_uris(): + return { + **required_style, + "type": "text", + "name": "redirect_uris", + "message": "Redirect URIs (comma-separated)", + "filter": filter_comma_separated, + "when": lambda x: x.get("client_type_id") == "idporten", + "validate": URIListValidator, + } + + +def q_post_logout_redirect_uris(): + return { + **required_style, + "type": "text", + "name": "post_logout_redirect_uris", + "message": "Post logout Redirect URIs (comma-separated)", + "filter": filter_comma_separated, + "when": lambda x: x.get("client_type_id") == "idporten", + "validate": URIListValidator, + } + + +def q_frontchannel_logout_uri(): + return { + **required_style, + "type": "text", + "name": "frontchannel_logout_uri", + "message": "Frontchannel logout URI", + "when": lambda x: x.get("client_type_id") == "idporten", + "validate": URIValidator, + } + + +def q_client_uri(): + return { + **required_style, + "type": "text", + "name": "client_uri", + "message": "Client URI/back URI", + "when": lambda x: x.get("client_type_id") == "idporten", + "validate": URIValidator, + } + + +def q_client(pubs_client, allow_all=False): def _client_choices(env): - clients = pubreg_client.get_clients(env) + clients = pubs_client.get_clients(env) if not clients: raise NoClientsError @@ -165,9 +226,7 @@ def q_aws_account(): "name": "aws_account", "message": "AWS account number", "when": lambda x: x.get("key_destination") == "aws" or x.get("delete_from_aws"), - "validate": ( - lambda t: bool(re.fullmatch("[0-9]{12}", t)) or "12 digits, please" - ), + "validate": AWSAccountValidator, } @@ -198,9 +257,9 @@ def q_enable_auto_rotate(): } -def q_key(pubreg_client): +def q_key(pubs_client): def _key_choices(env, client_id): - keys = pubreg_client.get_keys(env, client_id) + keys = pubs_client.get_keys(env, client_id) if not keys: raise NoKeysError diff --git a/okdata/cli/commands/pubreg/wizards.py b/okdata/cli/commands/pubreg/wizards.py index 7ec2f13..385b808 100644 --- a/okdata/cli/commands/pubreg/wizards.py +++ b/okdata/cli/commands/pubreg/wizards.py @@ -1,15 +1,19 @@ from okdata.cli.commands.pubreg.questions import ( - _providers, q_aws_account, q_aws_region, q_client, + q_client_type, + q_client_uri, q_delete_from_aws, q_enable_auto_rotate, q_env, + q_frontchannel_logout_uri, q_integration, q_key, q_key_destination, + q_post_logout_redirect_uris, q_provider, + q_redirect_uris, q_scopes, ) from okdata.cli.commands.teams.questions import q_team @@ -20,17 +24,26 @@ def create_client_wizard(team_client): choices = run_questionnaire( q_env(), q_team(team_client), + q_client_type(), q_provider(), q_scopes(), q_integration(), + q_redirect_uris(), + q_post_logout_redirect_uris(), + q_frontchannel_logout_uri(), + q_client_uri(), ) return { "env": choices["env"], "team_id": choices.get("team_id"), - "provider_id": choices["provider_id"], - "provider_name": _providers[choices["provider_id"]], + "client_type_id": choices["client_type_id"], + "provider_id": choices.get("provider_id"), "integration": choices["integration"], - "scopes": choices["scopes"], + "scopes": choices.get("scopes"), + "redirect_uris": choices.get("redirect_uris"), + "post_logout_redirect_uris": choices.get("post_logout_redirect_uris"), + "frontchannel_logout_uri": choices.get("frontchannel_logout_uri"), + "client_uri": choices.get("client_uri"), } @@ -39,10 +52,10 @@ def list_clients_wizard(): return {"env": choices["env"]} -def delete_client_wizard(pubreg_client): +def delete_client_wizard(pubs_client): choices = run_questionnaire( q_env(), - q_client(pubreg_client), + q_client(pubs_client), q_delete_from_aws(), q_aws_account(), q_aws_region(), @@ -57,10 +70,10 @@ def delete_client_wizard(pubreg_client): } -def create_key_wizard(pubreg_client): +def create_key_wizard(pubs_client): choices = run_questionnaire( q_env(), - q_client(pubreg_client), + q_client(pubs_client), q_key_destination(), q_aws_account(), q_aws_region(), @@ -77,8 +90,8 @@ def create_key_wizard(pubreg_client): } -def list_keys_wizard(pubreg_client): - choices = run_questionnaire(q_env(), q_client(pubreg_client, True)) +def list_keys_wizard(pubs_client): + choices = run_questionnaire(q_env(), q_client(pubs_client, True)) return { "env": choices["env"], **( @@ -94,11 +107,11 @@ def list_keys_wizard(pubreg_client): } -def delete_key_wizard(pubreg_client): +def delete_key_wizard(pubs_client): choices = run_questionnaire( q_env(), - q_client(pubreg_client), - q_key(pubreg_client), + q_client(pubs_client), + q_key(pubs_client), ) return { "env": choices["env"], @@ -108,8 +121,8 @@ def delete_key_wizard(pubreg_client): } -def audit_log_wizard(pubreg_client): - choices = run_questionnaire(q_env(), q_client(pubreg_client)) +def audit_log_wizard(pubs_client): + choices = run_questionnaire(q_env(), q_client(pubs_client)) return { "env": choices["env"], "client_id": choices["client"]["id"], diff --git a/okdata/cli/commands/datasets/validators.py b/okdata/cli/commands/validators.py similarity index 72% rename from okdata/cli/commands/datasets/validators.py rename to okdata/cli/commands/validators.py index bd5e3dd..e207032 100644 --- a/okdata/cli/commands/datasets/validators.py +++ b/okdata/cli/commands/validators.py @@ -1,6 +1,8 @@ -import re import csv import datetime +import re +from types import SimpleNamespace +from urllib.parse import urlparse from questionary import Validator, ValidationError @@ -120,3 +122,51 @@ def validate(self, document): cursor_position=len(document.text), ) return None + + +class IntegrationValidator(Validator): + def validate(self, document): + if len(document.text) > 30: + raise ValidationError( + message="Too long!", cursor_position=len(document.text) + ) + if not re.fullmatch("[0-9a-z-]+", document.text): + raise ValidationError( + message='Only lowercase letters, numbers and "-", please', + cursor_position=len(document.text), + ) + + +class AWSAccountValidator(Validator): + def validate(self, document): + if not re.fullmatch("[0-9]{12}", document.text): + raise ValidationError( + message="12 digits, please", cursor_position=len(document.text) + ) + + +class URIValidator(Validator): + def validate(self, document): + parsed = urlparse(document.text) + + if not (parsed.scheme and parsed.netloc): + raise ValidationError( + message="Please enter a valid URI, including scheme (e.g. https://...)", + cursor_position=len(document.text), + ) + + +class URIListValidator(Validator): + def validate(self, document): + uri_validator = URIValidator() + try: + for val in document.text.split(","): + uri_validator.validate(SimpleNamespace(text=val)) + except ValidationError: + raise ValidationError( + message=( + "Please enter a comma-separated list of URIs, including scheme " + "(e.g. https://...)" + ), + cursor_position=len(document.text), + ) diff --git a/okdata/cli/commands/wizard.py b/okdata/cli/commands/wizard.py index 02edff0..f898cd9 100644 --- a/okdata/cli/commands/wizard.py +++ b/okdata/cli/commands/wizard.py @@ -1,3 +1,4 @@ +import csv import sys from prompt_toolkit.styles import Style @@ -17,3 +18,10 @@ def run_questionnaire(*questions): sys.exit() return choices + + +def filter_comma_separated(value): + values = next( + csv.reader([value], delimiter=",", escapechar="\\", skipinitialspace=True) + ) + return [x.strip() for x in values if x] diff --git a/tests/origocli/commands/datasets/validators_test.py b/tests/origocli/commands/validators_test.py similarity index 64% rename from tests/origocli/commands/datasets/validators_test.py rename to tests/origocli/commands/validators_test.py index dd4207e..03b50d1 100644 --- a/tests/origocli/commands/datasets/validators_test.py +++ b/tests/origocli/commands/validators_test.py @@ -3,8 +3,10 @@ import pytest from questionary import ValidationError -from okdata.cli.commands.datasets.validators import ( +from okdata.cli.commands.validators import ( + AWSAccountValidator, DateValidator, + IntegrationValidator, KeywordValidator, PhoneValidator, SimpleEmailValidator, @@ -12,6 +14,8 @@ SpatialValidator, StandardsValidator, TitleValidator, + URIListValidator, + URIValidator, ) # Note: no testing of return values since the validator is only @@ -196,3 +200,90 @@ def test_gt_zero(self): def test_invalid_value(self): with pytest.raises(ValidationError): self.validate_document({"text": "ukjent"}) + + +class TestIntegrationValidator: + def validate_document(self, data): + validator = IntegrationValidator() + document = SimpleNamespace(**data) + validator.validate(document) + + def test_valid_integrations(self): + self.validate_document({"text": "x"}) + self.validate_document({"text": "a-b-c-1-2-3"}) + self.validate_document({"text": "q3v3avjd40dmpwicg7kn3xo8drbslu"}) + + def test_invalid_integrations(self): + with pytest.raises(ValidationError): + self.validate_document({"text": ""}) + with pytest.raises(ValidationError): + self.validate_document({"text": "foo_bar"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "foo bar"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "foobar😅"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "Foobar"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "qrmfffqgqzpvlmhmx3vvns3yhlrp9am"}) + + +class TestAWSAccountValidator: + def validate_document(self, data): + validator = AWSAccountValidator() + document = SimpleNamespace(**data) + validator.validate(document) + + def test_valid_aws_accounts(self): + self.validate_document({"text": "123456789101"}) + + def test_invalid_aws_accounts(self): + with pytest.raises(ValidationError): + self.validate_document({"text": ""}) + with pytest.raises(ValidationError): + self.validate_document({"text": "12345678910"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "1234567891012"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "rai3qqvwq8nh"}) + + +class TestURIValidator: + def validate_document(self, data): + validator = URIValidator() + document = SimpleNamespace(**data) + validator.validate(document) + + def test_valid_uris(self): + self.validate_document({"text": "http://localhost"}) + self.validate_document({"text": "http://localhost:8000"}) + self.validate_document({"text": "https://example.org"}) + + def test_invalid_uris(self): + with pytest.raises(ValidationError): + self.validate_document({"text": ""}) + with pytest.raises(ValidationError): + self.validate_document({"text": "localhost"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "example.org"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "http:/localhost"}) + + +class TestURIListValidator: + def validate_document(self, data): + validator = URIListValidator() + document = SimpleNamespace(**data) + validator.validate(document) + + def test_valid_uri_lists(self): + self.validate_document({"text": "http://localhost"}) + self.validate_document({"text": "http://localhost,http://localhost:8000"}) + + def test_invalid_uri_lists(self): + with pytest.raises(ValidationError): + self.validate_document({"text": ""}) + with pytest.raises(ValidationError): + self.validate_document({"text": "http://localhost,"}) + with pytest.raises(ValidationError): + self.validate_document({"text": "http://localhost,example.org"}) diff --git a/tests/origocli/commands/datasets/questions_tests.py b/tests/origocli/commands/wizard_test.py similarity index 95% rename from tests/origocli/commands/datasets/questions_tests.py rename to tests/origocli/commands/wizard_test.py index a8727aa..3a16dd4 100644 --- a/tests/origocli/commands/datasets/questions_tests.py +++ b/tests/origocli/commands/wizard_test.py @@ -1,4 +1,4 @@ -from okdata.cli.commands.datasets.questions import filter_comma_separated +from okdata.cli.commands.wizard import filter_comma_separated class TestCommaSeparatedFilter: From 49fa8611598c551915d3660b2ad2658bf6ca5756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simen=20Heggest=C3=B8yl?= Date: Fri, 8 Mar 2024 13:29:22 +0100 Subject: [PATCH 2/3] Rename "pubreg" files to "pubs" --- README.md | 2 +- doc/{pubreg.md => pubs.md} | 0 okdata/cli/__main__.py | 2 +- okdata/cli/commands/pubreg/__init__.py | 3 --- okdata/cli/commands/pubs/__init__.py | 3 +++ okdata/cli/commands/{pubreg => pubs}/client.py | 0 .../commands/{pubreg/pubreg.py => pubs/pubs.py} | 14 +++++++------- okdata/cli/commands/{pubreg => pubs}/questions.py | 0 okdata/cli/commands/{pubreg => pubs}/wizards.py | 2 +- ..._log_config.json => pubs_audit_log_config.json} | 0 ...lients_config.json => pubs_clients_config.json} | 0 ....json => pubs_multiple_client_keys_config.json} | 0 ...ig.json => pubs_single_client_keys_config.json} | 0 13 files changed, 13 insertions(+), 13 deletions(-) rename doc/{pubreg.md => pubs.md} (100%) delete mode 100644 okdata/cli/commands/pubreg/__init__.py create mode 100644 okdata/cli/commands/pubs/__init__.py rename okdata/cli/commands/{pubreg => pubs}/client.py (100%) rename okdata/cli/commands/{pubreg/pubreg.py => pubs/pubs.py} (96%) rename okdata/cli/commands/{pubreg => pubs}/questions.py (100%) rename okdata/cli/commands/{pubreg => pubs}/wizards.py (98%) rename okdata/cli/data/output-format/{pubreg_audit_log_config.json => pubs_audit_log_config.json} (100%) rename okdata/cli/data/output-format/{pubreg_clients_config.json => pubs_clients_config.json} (100%) rename okdata/cli/data/output-format/{pubreg_multiple_client_keys_config.json => pubs_multiple_client_keys_config.json} (100%) rename okdata/cli/data/output-format/{pubreg_single_client_keys_config.json => pubs_single_client_keys_config.json} (100%) diff --git a/README.md b/README.md index 73f8956..c29ebf7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Okdata CLI provides a unified interface to data services for Oslo Origo teams. * [Install](doc/install.md) * [Configuration](doc/configuration.md) -* [Public services](doc/pubreg.md) +* [Public services](doc/pubs.md) * [Teams](doc/teams.md) * [Datasets](doc/datasets.md) * [Permissions](doc/permissions.md) diff --git a/doc/pubreg.md b/doc/pubs.md similarity index 100% rename from doc/pubreg.md rename to doc/pubs.md diff --git a/okdata/cli/__main__.py b/okdata/cli/__main__.py index 0274f3b..c4e1e1c 100644 --- a/okdata/cli/__main__.py +++ b/okdata/cli/__main__.py @@ -9,7 +9,7 @@ from okdata.cli.command import BaseCommand from okdata.cli.commands.datasets import DatasetsCommand from okdata.cli.commands.permissions import PermissionsCommand -from okdata.cli.commands.pubreg import PubsCommand +from okdata.cli.commands.pubs import PubsCommand from okdata.cli.commands.status import StatusCommand from okdata.cli.commands.teams.teams import TeamsCommand diff --git a/okdata/cli/commands/pubreg/__init__.py b/okdata/cli/commands/pubreg/__init__.py deleted file mode 100644 index fbbac41..0000000 --- a/okdata/cli/commands/pubreg/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from okdata.cli.commands.pubreg.pubreg import PubsCommand - -__all__ = ["PubsCommand"] diff --git a/okdata/cli/commands/pubs/__init__.py b/okdata/cli/commands/pubs/__init__.py new file mode 100644 index 0000000..c25710d --- /dev/null +++ b/okdata/cli/commands/pubs/__init__.py @@ -0,0 +1,3 @@ +from okdata.cli.commands.pubs.pubs import PubsCommand + +__all__ = ["PubsCommand"] diff --git a/okdata/cli/commands/pubreg/client.py b/okdata/cli/commands/pubs/client.py similarity index 100% rename from okdata/cli/commands/pubreg/client.py rename to okdata/cli/commands/pubs/client.py diff --git a/okdata/cli/commands/pubreg/pubreg.py b/okdata/cli/commands/pubs/pubs.py similarity index 96% rename from okdata/cli/commands/pubreg/pubreg.py rename to okdata/cli/commands/pubs/pubs.py index 789c96d..1db4615 100644 --- a/okdata/cli/commands/pubreg/pubreg.py +++ b/okdata/cli/commands/pubs/pubs.py @@ -6,14 +6,14 @@ from okdata.cli import MAINTAINER from okdata.cli.command import BASE_COMMAND_OPTIONS, BaseCommand -from okdata.cli.commands.pubreg.client import PubsClient -from okdata.cli.commands.pubreg.questions import ( +from okdata.cli.commands.pubs.client import PubsClient +from okdata.cli.commands.pubs.questions import ( NoClientsError, NoKeysError, client_types, providers, ) -from okdata.cli.commands.pubreg.wizards import ( +from okdata.cli.commands.pubs.wizards import ( audit_log_wizard, create_client_wizard, create_key_wizard, @@ -125,7 +125,7 @@ def list_clients(self): config = list_clients_wizard() env = config["env"] clients = self.pubs_client.get_clients(env) - out = create_output(self.opt("format"), "pubreg_clients_config.json") + out = create_output(self.opt("format"), "pubs_clients_config.json") out.add_rows(sorted(clients, key=itemgetter("client_name"))) self.print(f"Clients in {env}:", out) @@ -285,7 +285,7 @@ def _list_client_keys_single(self, client_id, client_name, env): keys = self.pubs_client.get_keys(env, client_id) out = create_output( self.opt("format"), - "pubreg_single_client_keys_config.json", + "pubs_single_client_keys_config.json", ) out.add_rows(sorted(keys, key=itemgetter("expires"))) self.print(f"Keys for client {client_name} [{env}]:", out) @@ -299,7 +299,7 @@ def _list_client_keys_multiple(self, clients, env): ] out = create_output( self.opt("format"), - "pubreg_multiple_client_keys_config.json", + "pubs_multiple_client_keys_config.json", ) out.add_rows(sorted(keys, key=itemgetter("expires"))) self.print(f"All client keys [{env}]:", out) @@ -343,6 +343,6 @@ def audit_log(self): audit_log = self.pubs_client.get_audit_log(env, client_id) - out = create_output(self.opt("format"), "pubreg_audit_log_config.json") + out = create_output(self.opt("format"), "pubs_audit_log_config.json") out.add_rows(sorted(audit_log, key=itemgetter("timestamp"))) self.print(f"Audit log for client {client_name} [{env}]:", out) diff --git a/okdata/cli/commands/pubreg/questions.py b/okdata/cli/commands/pubs/questions.py similarity index 100% rename from okdata/cli/commands/pubreg/questions.py rename to okdata/cli/commands/pubs/questions.py diff --git a/okdata/cli/commands/pubreg/wizards.py b/okdata/cli/commands/pubs/wizards.py similarity index 98% rename from okdata/cli/commands/pubreg/wizards.py rename to okdata/cli/commands/pubs/wizards.py index 385b808..e4b56dd 100644 --- a/okdata/cli/commands/pubreg/wizards.py +++ b/okdata/cli/commands/pubs/wizards.py @@ -1,4 +1,4 @@ -from okdata.cli.commands.pubreg.questions import ( +from okdata.cli.commands.pubs.questions import ( q_aws_account, q_aws_region, q_client, diff --git a/okdata/cli/data/output-format/pubreg_audit_log_config.json b/okdata/cli/data/output-format/pubs_audit_log_config.json similarity index 100% rename from okdata/cli/data/output-format/pubreg_audit_log_config.json rename to okdata/cli/data/output-format/pubs_audit_log_config.json diff --git a/okdata/cli/data/output-format/pubreg_clients_config.json b/okdata/cli/data/output-format/pubs_clients_config.json similarity index 100% rename from okdata/cli/data/output-format/pubreg_clients_config.json rename to okdata/cli/data/output-format/pubs_clients_config.json diff --git a/okdata/cli/data/output-format/pubreg_multiple_client_keys_config.json b/okdata/cli/data/output-format/pubs_multiple_client_keys_config.json similarity index 100% rename from okdata/cli/data/output-format/pubreg_multiple_client_keys_config.json rename to okdata/cli/data/output-format/pubs_multiple_client_keys_config.json diff --git a/okdata/cli/data/output-format/pubreg_single_client_keys_config.json b/okdata/cli/data/output-format/pubs_single_client_keys_config.json similarity index 100% rename from okdata/cli/data/output-format/pubreg_single_client_keys_config.json rename to okdata/cli/data/output-format/pubs_single_client_keys_config.json From 00a8515c5f7190e683862fad32aa0c276c3a15d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simen=20Heggest=C3=B8yl?= Date: Mon, 11 Mar 2024 14:23:53 +0100 Subject: [PATCH 3/3] Client URI prompt tweak --- okdata/cli/commands/pubs/questions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/okdata/cli/commands/pubs/questions.py b/okdata/cli/commands/pubs/questions.py index becf91e..992f5d0 100644 --- a/okdata/cli/commands/pubs/questions.py +++ b/okdata/cli/commands/pubs/questions.py @@ -163,7 +163,7 @@ def q_client_uri(): **required_style, "type": "text", "name": "client_uri", - "message": "Client URI/back URI", + "message": "Client URI (back URI)", "when": lambda x: x.get("client_type_id") == "idporten", "validate": URIValidator, }