From 4a275c846df579fbd216fb27b66c3692fad61b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:04:57 +0100 Subject: [PATCH 01/10] test: api key tests --- .gitlab-ci.yml | 15 ++++++++++++++- setup.py | 26 -------------------------- 2 files changed, 14 insertions(+), 27 deletions(-) delete mode 100644 setup.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 718cc82..d5a058d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -77,7 +77,7 @@ pages: # --------------------------------- Test -------------------------------------- -Tests: +OAuth2 Tests: stage: Test except: - main @@ -89,6 +89,19 @@ Tests: - python tests/test_super-s2.py - python tests/test_push.py +API key Tests: + stage: Test + except: + - main + before_script: + - pip install . + - pip install pystac-client + script: + - dinamis_cli register + - python tests/test_spot-6-7-drs.py + - python tests/test_super-s2.py + - python tests/test_push.py + # --------------------------------- Ship -------------------------------------- pypi: diff --git a/setup.py b/setup.py deleted file mode 100644 index eb3fa2f..0000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -from setuptools import setup, find_packages - -install_requires = [ - "requests", - "qrcode", - "appdirs", - "pystac", - "pystac_client", - "pydantic>=1.7.3", - "pydantic_settings", - "packaging" -] - -setup( - name="dinamis-sdk", - version="0.2.2", - description="DINAMIS SDK", - python_requires=">=3.8", - author="Remi Cresson", - author_email="remi.cresson@inrae.fr", - license="MIT", - zip_safe=False, - install_requires=install_requires, - packages=find_packages(), -) - -- GitLab From fb8c8cb691b25aa28638ce850d5c29bbd6bcf26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:05:12 +0100 Subject: [PATCH 02/10] use pyproject.toml --- pyproject.toml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..824ea20 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "dinamis-sdk" +authors = [{name = "inrae", email = "remi.cresson@inrae.fr"}] +version = "0.3.0" +description = "DINAMIS SDK for Python" +requires-python = ">=3.7" +dependencies = [ + "click>=7.1", + "pydantic>=1.7.3", + "pystac>=1.0.0", + "pystac-client>=0.2.0", + "requests>=2.25.1", + "packaging", + "qrcode", + "appdirs", + "pydantic_settings", +] +readme = "README.md" +license = {file = "LICENSE"} + +[project.scripts] +dinamis_cli = "dinamis_sdk.cli:app" + +[tool.setuptools] +include-package-data = false -- GitLab From f7b2d5f1418c6513827394f50b8ba558b8812802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:05:24 +0100 Subject: [PATCH 03/10] enh: implement api key support --- dinamis_sdk/cli.py | 41 ++++++++++++++++++++++ dinamis_sdk/s3.py | 13 +++---- dinamis_sdk/settings.py | 1 + dinamis_sdk/utils.py | 75 ++++++++++++++++++++--------------------- 4 files changed, 83 insertions(+), 47 deletions(-) create mode 100644 dinamis_sdk/cli.py diff --git a/dinamis_sdk/cli.py b/dinamis_sdk/cli.py new file mode 100644 index 0000000..d08834b --- /dev/null +++ b/dinamis_sdk/cli.py @@ -0,0 +1,41 @@ +import click +from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT +from .auth import get_access_token +import os +import json + + +@click.group(help="Dinamis CLI") +def app() -> None: + """Click group for dinamis sdk subcommands""" + pass + + +def create_key() -> dict: + """ + Create an API key + """ + session = create_session() + ret = session.get( + f"{S3_SIGNING_ENDPOINT}create_api_key", + timeout=5, + headers={"authorization": f"bearer {get_access_token()}"} + ) + ret.raise_for_status() + return ret.json() + + +@app.command(help="Get and store an API key") +def register(): + with open(APIKEY_FILE, 'w') as f: + json.dump(create_key(), f) + print(f"API key successfully created and stored in {APIKEY_FILE}") + + +@app.command(help="Delete the stored API key") +def delete(): + if os.path.isfile(APIKEY_FILE): + os.remove(APIKEY_FILE) + print(f"File {APIKEY_FILE} deleted!") + else: + print("No API key stored!") diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index 79481db..d08cfdc 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -26,11 +26,11 @@ import pydantic from .utils import ( log, settings, - CREDENTIALS, MAX_URLS, S3_SIGNING_ENDPOINT, S3_STORAGE_DOMAIN, - create_session + create_session, + APIKEY ) _PYDANTIC_2_0 = packaging.version.parse( @@ -474,12 +474,9 @@ def _generic_get_signed_urls( "Content-Type": "application/json", "Accept": "application/json" } - if CREDENTIALS: - headers.update({ - "dinamis-access-key": CREDENTIALS.access_key, - "dinamis-secret-key": CREDENTIALS.secret_key - }) - log.debug("Using credentials (access/secret keys)") + if APIKEY: + headers.update(APIKEY) + log.debug("Using API key") elif settings.dinamis_sdk_bypass_api: log.debug("Using bypass API %s", settings.dinamis_sdk_bypass_api) else: diff --git a/dinamis_sdk/settings.py b/dinamis_sdk/settings.py index 3aba92c..b4386ef 100644 --- a/dinamis_sdk/settings.py +++ b/dinamis_sdk/settings.py @@ -9,3 +9,4 @@ class Settings(BaseSettings): dinamis_sdk_url_duration: int = 0 dinamis_sdk_bypass_api: str = "" dinamis_sdk_token_server: str = "" + dinamis_sdk_settings_dir: str = "" \ No newline at end of file diff --git a/dinamis_sdk/utils.py b/dinamis_sdk/utils.py index 65e9a29..80c7c1b 100644 --- a/dinamis_sdk/utils.py +++ b/dinamis_sdk/utils.py @@ -23,47 +23,63 @@ S3_SIGNING_ENDPOINT = \ "https://s3-signing-cdos.apps.okd.crocc.meso.umontpellier.fr/" # Config path -CFG_PTH = appdirs.user_config_dir(appname='dinamis_sdk_auth') +CFG_PTH = settings.dinamis_sdk_settings_dir or \ + appdirs.user_config_dir(appname='dinamis_sdk_auth') if not os.path.exists(CFG_PTH): try: os.makedirs(CFG_PTH) - log.debug("Config path created in %s", CFG_PTH) + log.debug("Settings dir created in %s", CFG_PTH) except PermissionError: - log.warning("Unable to create config path") + log.warning("Unable to create settings dir") CFG_PTH = None else: - log.debug("Config path already exist in %s", CFG_PTH) + log.debug("Using existing settings dir %s", CFG_PTH) # JWT File JWT_FILE = os.path.join(CFG_PTH, ".token") if CFG_PTH else None log.debug("JWT file is %s", JWT_FILE) -# Settings file -settings_file = os.path.join(CFG_PTH, ".settings") if CFG_PTH else None -log.debug("Settings file is %s", settings_file) +# API key File +APIKEY_FILE = os.path.join(CFG_PTH, ".api_key") if CFG_PTH else None +log.debug("API key file is %s", APIKEY_FILE) +APIKEY = None +if APIKEY_FILE and os.path.isfile(APIKEY_FILE): + try: + log.debug("Found a stored API key") + with open(APIKEY_FILE, encoding='UTF-8') as json_file: + APIKEY = json.load(json_file) + log.debug("API key successfully loaded") + except json.decoder.JSONDecodeError: + log.warning("Stored API key file is invalid. Deleting it.") + os.remove(APIKEY_FILE) -class StorageCredentials(BaseModel): # pylint: disable = R0903 - """Credentials model.""" - - access_key: str - secret_key: str +def create_session( + retry_total: int = 5, + retry_backoff_factor: float = .8 +): + """Create a session for requests.""" + session = requests.Session() + retry = urllib3.util.retry.Retry( + total=retry_total, + backoff_factor=retry_backoff_factor, + status_forcelist=[404, 429, 500, 502, 503, 504], + allowed_methods=False, + ) + adapter = requests.adapters.HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) -CREDENTIALS = None -if settings_file and os.path.isfile(settings_file): - try: - with open(settings_file, encoding='UTF-8') as json_file: - CREDENTIALS = StorageCredentials(**json.load(json_file)) - except FileNotFoundError: - log.debug("Setting file %s does not exist", settings_file) + return session def retrieve_token_endpoint(s3_signing_endpoint: str = S3_SIGNING_ENDPOINT): """Retrieve the token endpoint from the s3 signing endpoint.""" openapi_url = s3_signing_endpoint + "openapi.json" log.debug("Fetching OAuth2 endpoint from openapi url %s", openapi_url) - res = requests.get( + session = create_session() + res = session.get( openapi_url, timeout=10, ) @@ -84,22 +100,3 @@ TOKEN_ENDPOINT = None if settings.dinamis_sdk_bypass_api \ # crocc.meso.umontpellier.fr/auth/realms/dinamis/protocol/openid-connect AUTH_BASE_URL = None if settings.dinamis_sdk_bypass_api \ else TOKEN_ENDPOINT.rsplit('/', 1)[0] - - -def create_session( - retry_total: int = 5, - retry_backoff_factor: float = .8 -): - """Create a session for requests.""" - session = requests.Session() - retry = urllib3.util.retry.Retry( - total=retry_total, - backoff_factor=retry_backoff_factor, - status_forcelist=[404, 429, 500, 502, 503, 504], - allowed_methods=False, - ) - adapter = requests.adapters.HTTPAdapter(max_retries=retry) - session.mount("http://", adapter) - session.mount("https://", adapter) - - return session -- GitLab From 41e3c7ff6cdced8449443854e3ef77a6fa9e17a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:11:00 +0100 Subject: [PATCH 04/10] test: api key tests --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d5a058d..3ebc5d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -82,7 +82,7 @@ OAuth2 Tests: except: - main before_script: - - pip install . + - pip install ./dinamis_sdk - pip install pystac-client script: - python tests/test_spot-6-7-drs.py @@ -94,7 +94,7 @@ API key Tests: except: - main before_script: - - pip install . + - pip install ./dinamis_sdk - pip install pystac-client script: - dinamis_cli register -- GitLab From 92e6eceb296b98b0edceb6a94b4f7012202e0668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:16:12 +0100 Subject: [PATCH 05/10] test: api key tests --- .gitlab-ci.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3ebc5d6..169a40e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -77,25 +77,24 @@ pages: # --------------------------------- Test -------------------------------------- -OAuth2 Tests: +.tests_base: stage: Test except: - main before_script: - - pip install ./dinamis_sdk + - pip install $PWD/dinamis_sdk - pip install pystac-client + + +OAuth2 Tests: + extends: .tests_base script: - python tests/test_spot-6-7-drs.py - python tests/test_super-s2.py - python tests/test_push.py API key Tests: - stage: Test - except: - - main - before_script: - - pip install ./dinamis_sdk - - pip install pystac-client + extends: .tests_base script: - dinamis_cli register - python tests/test_spot-6-7-drs.py -- GitLab From e3c917c5f538ea99e45b34672e80a81de62c9572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:45:04 +0100 Subject: [PATCH 06/10] test: api key tests --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 169a40e..d659a35 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,8 +9,8 @@ workflow: stages: - Static Analysis - Install - - Documentation - Test + - Documentation - Ship # ------------------------------ Static analysis ------------------------------ @@ -82,7 +82,7 @@ pages: except: - main before_script: - - pip install $PWD/dinamis_sdk + - pip install . - pip install pystac-client -- GitLab From 4b2aa85a766d402fb913419148fca222782537b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:56:02 +0100 Subject: [PATCH 07/10] add: various api key operations --- dinamis_sdk/cli.py | 55 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/dinamis_sdk/cli.py b/dinamis_sdk/cli.py index d08834b..b9066c5 100644 --- a/dinamis_sdk/cli.py +++ b/dinamis_sdk/cli.py @@ -3,6 +3,7 @@ from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT from .auth import get_access_token import os import json +from typing import List, Dict @click.group(help="Dinamis CLI") @@ -11,18 +12,60 @@ def app() -> None: pass -def create_key() -> dict: - """ - Create an API key - """ +def http(route: str): + """Perform an HTTP request.""" session = create_session() ret = session.get( - f"{S3_SIGNING_ENDPOINT}create_api_key", + f"{S3_SIGNING_ENDPOINT}{route}", timeout=5, headers={"authorization": f"bearer {get_access_token()}"} ) ret.raise_for_status() - return ret.json() + + +def create_key() -> Dict[str, str]: + """Create an API key.""" + return ret("create_api_key").json() + + +def list_keys() -> List[str]: + """List all generated API keys.""" + return ret("list_api_keys").json() + + +def revoke_key(key: str): + """Revoke an API key.""" + ret(f"revoke_api_key?key={key}") + print(f"API key {key} revoked") + + +@app.command(help="Create and show a new API key") +def create(): + print(f"Got a new API key: {create_key()}") + + +@app.command(help="List all API keys") +def list(): + print(f"All generated API keys: {list_keys()}") + + +@app.command(help="Revoke all API keys") +def revoke_all(): + keys = list_keys() + for key in keys: + revoke_key(key) + if not keys: + print("No API key found.") + + +@app.command(help="Revoke an API key") +@click.option( + "--key", + prompt="Please enter the access key to revoke", + help="Access key to revoke", +) +def revoke(key: str): + revoke_key(key) @app.command(help="Get and store an API key") -- GitLab From 3fc2a13253de1707e6fddb8cea26fd9dfcf5c8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 18:56:12 +0100 Subject: [PATCH 08/10] doc: api key documentation --- doc/credentials.md | 38 ++++++++++++++++++++++++++++++++------ doc/index.md | 16 +++++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/doc/credentials.md b/doc/credentials.md index 9ff33d6..936d76d 100644 --- a/doc/credentials.md +++ b/doc/credentials.md @@ -1,15 +1,41 @@ # Credentials +There is two ways of authenticating to the THEIA-MTP Geospatial data +infrastructure: + +- OAuth2 +- API key + +## OAuth2 + The credentials are retrieved using the device code flow on the first call of -`dinamis_sdk.sign_inplace()`. Just follow the instructions! +`dinamis_sdk.sign_inplace()`. Just follow the instructions, i.e. click on the +HTTP link, or scan the QR-code. + +The credentials are valid for 5 days. Every time `dinamis_sdk.sign_inplace()` +is called, the credentials are renewed for another 5 days. After 5 days idle, +you will have to log in again. + +## API key + +Use `dinamis_cli` to register an API key, that will be created and stored into +your local home directory. + +```commandline +dinamis_cli register +``` + +Just follow the instructions to login a single time, then the API key can be +used forever on your local computer. You can duplicate the API key file on +other computers. -## Renewal +You can delete the API key any time with: -The credentials are valid for 5 days. Every time -`dinamis_sdk.sign_inplace` is called, the credentials are renewed for another -5 days. After 5 days idle, you will have to log in again. +```commandline +dinamis_cli delete +``` -## Signed URLs +## Signed URLs expiry The signed URLs for STAC objects assets are valid during 7 days starting after `dinamis_sdk.sign_inplace` is called. diff --git a/doc/index.md b/doc/index.md index 93e6754..bfb4267 100644 --- a/doc/index.md +++ b/doc/index.md @@ -30,13 +30,16 @@ pip install dinamis-sdk ## Quickstart -This library assists with signing STAC items assets URLs from the DINAMIS SDI -prototype. The `sign` function operates directly on an HREF string, as well as -several [PySTAC](https://github.com/stac-utils/pystac) objects: `Asset`, -`Item`, and `ItemCollection`. In addition, the `sign` function accepts a -[STAC API Client](https://pystac-client.readthedocs.io/en/stable/) +This library assists with signing STAC items assets URLs from the THEIA-MTP +Geospatial Data Infrastructure. +The `sign_inplace` function operates directly on an HREF string, as well as +several [PySTAC](https://github.com/stac-utils/pystac) objects: `Asset`, `Item`, and `ItemCollection`. +In addition, the `sign_inplace` function accepts a [STAC API Client](https://pystac-client.readthedocs.io/en/stable/) `ItemSearch`, which performs a search and returns the resulting `ItemCollection` with all assets signed. +`sign_inplace()` can be used as a `modifier` in `pystac_client.Client` +instances, as shown in the example below. Alternatively, `sign()` can be used +to sign a single url. ```python import dinamis_sdk @@ -49,8 +52,7 @@ api = pystac_client.Client.open( ``` Follow the instructions to authenticate. -Read the [credentials section](credentials) to know more about credential -expiry. +Read the [credentials section](credentials) to know more about credentials. ## Contribute -- GitLab From e51638e5e12e4833164d732651c96ba7811e1f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 20:23:25 +0100 Subject: [PATCH 09/10] add: cli commands for api key management --- dinamis_sdk/cli.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/dinamis_sdk/cli.py b/dinamis_sdk/cli.py index b9066c5..9c13f91 100644 --- a/dinamis_sdk/cli.py +++ b/dinamis_sdk/cli.py @@ -1,5 +1,5 @@ import click -from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT +from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT, log from .auth import get_access_token import os import json @@ -8,7 +8,7 @@ from typing import List, Dict @click.group(help="Dinamis CLI") def app() -> None: - """Click group for dinamis sdk subcommands""" + """Click group for dinamis sdk subcommands.""" pass @@ -21,41 +21,45 @@ def http(route: str): headers={"authorization": f"bearer {get_access_token()}"} ) ret.raise_for_status() + return ret def create_key() -> Dict[str, str]: """Create an API key.""" - return ret("create_api_key").json() + return http("create_api_key").json() def list_keys() -> List[str]: """List all generated API keys.""" - return ret("list_api_keys").json() + return http("list_api_keys").json() def revoke_key(key: str): """Revoke an API key.""" - ret(f"revoke_api_key?key={key}") - print(f"API key {key} revoked") + http(f"revoke_api_key?access_key={key}") + log.info(f"API key {key} revoked") @app.command(help="Create and show a new API key") def create(): - print(f"Got a new API key: {create_key()}") + """Create and show a new API key.""" + log.info(f"Got a new API key: {create_key()}") @app.command(help="List all API keys") def list(): - print(f"All generated API keys: {list_keys()}") + """List all API keys.""" + log.info(f"All generated API keys: {list_keys()}") @app.command(help="Revoke all API keys") def revoke_all(): + """Revoke all API keys.""" keys = list_keys() for key in keys: revoke_key(key) if not keys: - print("No API key found.") + log.info("No API key found.") @app.command(help="Revoke an API key") @@ -65,20 +69,23 @@ def revoke_all(): help="Access key to revoke", ) def revoke(key: str): + """Revoke an API key.""" revoke_key(key) @app.command(help="Get and store an API key") def register(): + """Get and store an API key.""" with open(APIKEY_FILE, 'w') as f: json.dump(create_key(), f) - print(f"API key successfully created and stored in {APIKEY_FILE}") + log.info(f"API key successfully created and stored in {APIKEY_FILE}") @app.command(help="Delete the stored API key") def delete(): + """Delete the stored API key.""" if os.path.isfile(APIKEY_FILE): os.remove(APIKEY_FILE) - print(f"File {APIKEY_FILE} deleted!") + log.info(f"File {APIKEY_FILE} deleted!") else: - print("No API key stored!") + log.info("No API key stored!") -- GitLab From f127a236c6b79b38a2fa2280df712abb85115f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= <remi.cresson@inrae.fr> Date: Fri, 22 Nov 2024 20:40:25 +0100 Subject: [PATCH 10/10] sty: refac --- dinamis_sdk/cli.py | 1 + dinamis_sdk/settings.py | 2 +- dinamis_sdk/utils.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dinamis_sdk/cli.py b/dinamis_sdk/cli.py index 9c13f91..f900afa 100644 --- a/dinamis_sdk/cli.py +++ b/dinamis_sdk/cli.py @@ -1,3 +1,4 @@ +"""Dinamis Command Line Interface.""" import click from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT, log from .auth import get_access_token diff --git a/dinamis_sdk/settings.py b/dinamis_sdk/settings.py index b4386ef..37ba8b3 100644 --- a/dinamis_sdk/settings.py +++ b/dinamis_sdk/settings.py @@ -9,4 +9,4 @@ class Settings(BaseSettings): dinamis_sdk_url_duration: int = 0 dinamis_sdk_bypass_api: str = "" dinamis_sdk_token_server: str = "" - dinamis_sdk_settings_dir: str = "" \ No newline at end of file + dinamis_sdk_settings_dir: str = "" diff --git a/dinamis_sdk/utils.py b/dinamis_sdk/utils.py index 80c7c1b..bcc93e7 100644 --- a/dinamis_sdk/utils.py +++ b/dinamis_sdk/utils.py @@ -3,7 +3,6 @@ import json import logging import os import appdirs -from pydantic import BaseModel # pylint: disable = no-name-in-module import requests import urllib3.util.retry from .settings import Settings -- GitLab