Repository: reanahub/reana-client

Issue:
cli: implement `list --shared` argument

Discussion:
mdonadoni: Latest version before rebase:  c429f3e 

Still many comments to be addressed
mdonadoni: Ready to be reviewed, squashed and merged. CI should pass after a release of r-commons


Patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index d728b4b..c8a3060 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -7,6 +7,7 @@ The list of contributors in alphabetical order:
 - [Audrius Mecionis](https://orcid.org/0000-0002-3759-1663)
 - [Camila Diaz](https://orcid.org/0000-0001-5543-797X)
 - [Clemens Lange](https://orcid.org/0000-0002-3632-3157)
+- [Daan Rosendal](https://orcid.org/0000-0002-3447-9000)
 - [Daniel Prelipcean](https://orcid.org/0000-0002-4855-194X)
 - [Diego Rodriguez](https://orcid.org/0000-0003-0649-2002)
 - [Dinos Kousidis](https://orcid.org/0000-0002-4914-4289)
diff --git a/Dockerfile b/Dockerfile
index 234bbab..d1107bf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,6 +22,7 @@ COPY . /code
 RUN apt-get update -y && \
     apt-get install --no-install-recommends -y \
       gcc \
+      git \
       libpython3.12 \
       python3-pip \
       python3.12 \
diff --git a/docs/cmd_list.txt b/docs/cmd_list.txt
index 9f9b7f7..0b9a7d3 100644
--- a/docs/cmd_list.txt
+++ b/docs/cmd_list.txt
@@ -30,6 +30,11 @@ Workflow execution commands:
   stop      Stop a running workflow.
   validate  Validate workflow specification file.
 
+Workflow sharing commands:
+  share-add     Share a workflow with other users (read-only).
+  share-remove  Unshare a workflow.
+  share-status  Show with whom a workflow is shared.
+
 Workspace interactive commands:
   close  Close an interactive session.
   open   Open an interactive session inside the workspace.
diff --git a/reana_client/api/client.py b/reana_client/api/client.py
index ecdfd5e..8263feb 100644
--- a/reana_client/api/client.py
+++ b/reana_client/api/client.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 #
 # This file is part of REANA.
-# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN.
+# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023 CERN.
 #
 # REANA is free software; you can redistribute it and/or modify it
 # under the terms of the MIT License; see LICENSE file for more details.
@@ -17,20 +17,19 @@ from urllib.parse import urljoin
 
 import requests
 from bravado.exception import HTTPError
-from reana_commons.validation.utils import validate_reana_yaml, validate_workflow_name
-from reana_commons.specification import (
-    load_workflow_spec_from_reana_yaml,
-    load_input_parameters,
-)
+from reana_client.config import ERROR_MESSAGES
+from reana_client.errors import FileDeletionError, FileUploadError
+from reana_client.utils import is_regular_path, is_uuid_v4
 from reana_commons.api_client import get_current_api_client
 from reana_commons.config import REANA_WORKFLOW_ENGINES
 from reana_commons.errors import REANASecretAlreadyExists, REANASecretDoesNotExist
+from reana_commons.specification import (
+    load_input_parameters,
+    load_workflow_spec_from_reana_yaml,
+)
+from reana_commons.validation.utils import validate_reana_yaml, validate_workflow_name
 from werkzeug.local import LocalProxy
 
-from reana_client.config import ERROR_MESSAGES
-from reana_client.errors import FileDeletionError, FileUploadError
-from reana_client.utils import is_uuid_v4, is_regular_path
-
 current_rs_api_client = LocalProxy(
     partial(get_current_api_client, component="reana-server")
 )
@@ -116,6 +115,9 @@ def get_workflows(
     include_progress=None,
     include_workspace_size=None,
     workflow=None,
+    shared=None,
+    shared_by=None,
+    shared_with=None,
 ):
     """List all existing workflows.
 
@@ -131,6 +133,9 @@ def get_workflows(
     :param include_progress: include progress information in the response.
     :param include_workspace_size: include workspace size information in the response.
     :param workflow: name or id of the workflow.
+    :param shared: list all shared (owned and unowned) workflows.
+    :param shared_by: list workflows shared by the specified user(s).
+    :param shared_with: list workflows shared with the specified user(s).
 
     :return: a list of dictionaries with the information about the workflows.
              The information includes the workflow ``name``, ``id``, ``status``, ``size``,
@@ -149,6 +154,9 @@ def get_workflows(
             include_progress=include_progress,
             include_workspace_size=include_workspace_size,
             workflow_id_or_name=workflow,
+            shared=shared,
+            shared_by=shared_by,
+            shared_with=shared_with,
         ).result()
         if http_response.status_code == 200:
             return response.get("items")
@@ -1280,3 +1288,124 @@ def prune_workspace(workflow, include_inputs, include_outputs, access_token):
             )
         )
         raise Exception(e.response.json()["message"])
+
+
+def share_workflow(
+    workflow, user_email_to_share_with, access_token, message=None, valid_until=None
+):
+    """Share a workflow with a user.
+
+    :param workflow: name or id of the workflow.
+    :param user_email_to_share_with: user to share the workflow with.
+    :param access_token: access token of the current user.
+    :param message: Optional message to include when sharing the workflow.
+    :param valid_until: Specify the date when access to the workflow will expire (format: YYYY-MM-DD).
+
+    :return: a dictionary containing the ``workflow_id``, ``workflow_name``, and
+             a ``message`` key with the result of the operation.
+    """
+    try:
+        share_params = {
+            "user_email_to_share_with": user_email_to_share_with,
+        }
+
+        if message:
+            share_params["message"] = message
+
+        if valid_until:
+            share_params["valid_until"] = valid_until
+
+        (response, http_response) = current_rs_api_client.api.share_workflow(
+            workflow_id_or_name=workflow,
+            share_details=share_params,
+            access_token=access_token,
+        ).result()
+
+        if http_response.status_code == 200:
+            return response
+        else:
+            raise Exception(
+                "Expected status code 200 but replied with "
+                f"{http_response.status_code}"
+            )
+
+    except HTTPError as e:
+        logging.debug(
+            "Workflow could not be shared: "
+            f"\nStatus: {e.response.status_code}\nReason: {e.response.reason}\n"
+            f"Message: {e.response.json()['message']}"
+        )
+        raise Exception(e.response.json()["message"])
+
+
+def unshare_workflow(workflow, user_email_to_unshare_with, access_token):
+    """Unshare a workflow with a user.
+
+    :param workflow: name or id of the workflow.
+    :param user_email_to_unshare_with: user to unshare the workflow with.
+    :param access_token: access token of the current user.
+
+    :return: a dictionary containing the ``workflow_id``, ``workflow_name``, and
+             a ``message`` key with the result of the operation.
+    """
+    try:
+        unshare_params = {
+            "workflow_id_or_name": workflow,
+            "user_email_to_unshare_with": user_email_to_unshare_with,
+            "access_token": access_token,
+        }
+
+        (response, http_response) = current_rs_api_client.api.unshare_workflow(
+            **unshare_params
+        ).result()
+
+        if http_response.status_code == 200:
+            return response
+        else:
+            raise Exception(
+                "Expected status code 200 but replied with "
+                f"{http_response.status_code}"
+            )
+
+    except HTTPError as e:
+        logging.debug(
+            "Workflow could not be unshared: "
+            f"\nStatus: {e.response.status_code}\nReason: {e.response.reason}\n"
+            f"Message: {e.response.json()['message']}"
+        )
+        raise Exception(e.response.json()["message"])
+
+
+def get_workflow_sharing_status(workflow, access_token):
+    """Get the share status of a workflow.
+
+    :param workflow: name or id of the workflow.
+    :param access_token: access token of the current user.
+
+    :return: a dictionary containing the ``workflow_id``, ``workflow_name``, and
+             a ``sharing_status`` key with the result of the operation.
+    """
+    try:
+        (
+            response,
+            http_response,
+        ) = current_rs_api_client.api.get_workflow_share_status(
+            workflow_id_or_name=workflow,
+            access_token=access_token,
+        ).result()
+
+        if http_response.status_code == 200:
+            return response
+        else:
+            raise Exception(
+                "Expected status code 200 but replied with "
+                f"{http_response.status_code}"
+            )
+
+    except HTTPError as e:
+        logging.debug(
+            "Workflow sharing status could not be retrieved: "
+            f"\nStatus: {e.response.status_code}\nReason: {e.response.reason}\n"
+            f"Message: {e.response.json()['message']}"
+        )
+        raise Exception(e.response.json()["message"])
diff --git a/reana_client/cli/__init__.py b/reana_client/cli/__init__.py
index 508f0c7..2b46cf0 100644
--- a/reana_client/cli/__init__.py
+++ b/reana_client/cli/__init__.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 #
 # This file is part of REANA.
-# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN.
+# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023 CERN.
 #
 # REANA is free software; you can redistribute it and/or modify it
 # under the terms of the MIT License; see LICENSE file for more details.
@@ -11,10 +11,9 @@ import os
 import sys
 
 import click
-from urllib3 import disable_warnings
-
-from reana_client.cli import workflow, files, ping, secrets, quotas, retention_rules
+from reana_client.cli import files, ping, quotas, retention_rules, secrets, workflow
 from reana_client.utils import get_api_url
+from urllib3 import disable_warnings
 
 DEBUG_LOG_FORMAT = (
     "[%(asctime)s] p%(process)s "
@@ -41,6 +40,7 @@ class ReanaCLI(click.Group):
         ping.configuration_group,
         workflow.workflow_management_group,
         workflow.workflow_execution_group,
+        workflow.workflow_sharing_group,
         workflow.interactive_group,
         files.files_group,
         retention_rules.retention_rules_group,
diff --git a/reana_client/cli/workflow.py b/reana_client/cli/workflow.py
index 12b4eb6..1706a4a 100644
--- a/reana_client/cli/workflow.py
+++ b/reana_client/cli/workflow.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 #
 # This file is part of REANA.
-# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023 CERN.
+# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 CERN.
 #
 # REANA is free software; you can redistribute it and/or modify it
 # under the terms of the MIT License; see LICENSE file for more details.
@@ -15,12 +15,8 @@ import time
 import traceback
 
 import click
-from jsonschema.exceptions import ValidationError
-from reana_commons.config import INTERACTIVE_SESSION_TYPES, REANA_COMPUTE_BACKENDS
-from reana_commons.errors import REANAValidationError
-from reana_commons.validation.operational_options import validate_operational_options
 import yaml
-
+from jsonschema.exceptions import ValidationError
 from reana_client.cli.files import get_files, upload_files
 from reana_client.cli.utils import (
     add_access_token_options,
@@ -51,6 +47,9 @@ from reana_client.validation.utils import (
     validate_input_parameters,
     validate_workflow_name_parameter,
 )
+from reana_commons.config import INTERACTIVE_SESSION_TYPES, REANA_COMPUTE_BACKENDS
+from reana_commons.errors import REANAValidationError
+from reana_commons.validation.operational_options import validate_operational_options
 
 
 @click.group(help="Workflow management commands")
@@ -67,6 +66,13 @@ def workflow_execution_group(ctx):
     logging.debug(ctx.info_name)
 
 
+@click.group(help="Workflow sharing commands")
+@click.pass_context
+def workflow_sharing_group(ctx):
+    """Top level wrapper for workflow sharing."""
+    logging.debug(ctx.info_name)
+
+
 @workflow_management_group.command("list")
 @click.option(
     "-w",
@@ -151,6 +157,25 @@ def workflow_execution_group(ctx):
     default=False,
     help="Include deleted workflows in the output.",
 )
+@click.option(
+    "--shared",
+    "shared",
+    is_flag=True,
+    default=False,
+    help="List all shared (owned and unowned) workflows.",
+)
+@click.option(
+    "--shared-by",
+    "shared_by",
+    default=None,
+    help="List workflows shared by the specified user.",
+)
+@click.option(
+    "--shared-with",
+    "shared_with",
+    default=None,
+    help="List workflows shared with the specified user.",
+)
 @add_access_token_options
 @add_pagination_options
 @check_connection
@@ -173,21 +198,42 @@ def workflows_list(  # noqa: C901
     include_progress,
     include_workspace_size,
     show_deleted_runs: bool,
+    shared,
+    shared_by,
+    shared_with,
 ):  # noqa: D301
     """List all workflows and sessions.
 
     The ``list`` command lists workflows and sessions. By default, the list of
     workflows is returned. If you would like to see the list of your open
     interactive sessions, you need to pass the ``--sessions`` command-line
-    option.
+    option. If you would like to see the list of all workflows, including those
+    shared with you, you need to pass the ``--shared`` command-line option.
 
-    Example:\n
+    Along with specific user emails, you can pass the following special values
+    to the ``--shared-by`` and ``--shared-with`` command-line options:\n
+    \t - ``--shared-by anybody``: list workflows shared with you by anybody.\n
+    \t - ``--shared-with anybody``: list your shared workflows exclusively.\n
+    \t - ``--shared-with nobody``: list your unshared workflows exclusively.\n
+    \t - ``--shared-with bob@cern.ch``: list workflows shared with bob@cern.ch
+
+    Examples:\n
     \t $ reana-client list --all\n
     \t $ reana-client list --sessions\n
-    \t $ reana-client list --verbose --bytes
+    \t $ reana-client list --verbose --bytes\n
+    \t $ reana-client list --shared\n
+    \t $ reana-client list --shared-by bob@cern.ch\n
+    \t $ reana-client list --shared-with anybody
     """
     from reana_client.api.client import get_workflows
 
+    if shared_by and shared_with:
+        display_message(
+            "Please provide either --shared-by or --shared-with, not both.",
+            msg_type="error",
+        )
+        sys.exit(1)
+
     logging.debug("command: {}".format(ctx.command_path.replace(" ", ".")))
     for p in ctx.params:
         logging.debug("{param}: {value}".format(param=p, value=ctx.params[p]))
@@ -218,13 +264,23 @@ def workflows_list(  # noqa: C901
             include_progress=include_progress,
             include_workspace_size=include_workspace_size,
             workflow=workflow,
+            shared=shared,
+            shared_by=shared_by,
+            shared_with=shared_with,
         )
         verbose_headers = ["id", "user"]
         workspace_size_header = ["size"]
         progress_header = ["progress"]
         duration_header = ["duration"]
         headers = {
-            "batch": ["name", "run_number", "created", "started", "ended", "status"],
+            "batch": [
+                "name",
+                "run_number",
+                "created",
+                "started",
+                "ended",
+                "status",
+            ],
             "interactive": [
                 "name",
                 "run_number",
@@ -243,6 +299,14 @@ def workflows_list(  # noqa: C901
         if verbose or include_duration:
             headers[type] += duration_header
 
+        if shared:
+            headers[type] += ["shared_with", "shared_by"]
+        else:
+            if shared_with:
+                headers[type] += ["shared_with"]
+            if shared_by:
+                headers[type] += ["shared_by"]
+
         data = []
         for workflow in response:
             name, run_number = get_workflow_name_and_run_number(workflow["name"])
@@ -265,6 +329,10 @@ def workflows_list(  # noqa: C901
                         "run_started_at" if header == "started" else "run_finished_at"
                     )
                     value = workflow.get("progress", {}).get(_key) or "-"
+                if header == "shared_by":
+                    value = workflow.get("owner_email")
+                if header == "shared_with":
+                    value = ", ".join(workflow.get("shared_with", []))
                 if not value:
                     value = workflow.get(header)
                 row.append(value)
@@ -451,12 +519,12 @@ def workflow_start(
     \t $ reana-client start -w myanalysis.42 -p sleeptime=10 -p myparam=4\n
     \t $ reana-client start -w myanalysis.42 -p myparam1=myvalue1 -o CACHE=off
     """
-    from reana_client.utils import get_api_url
     from reana_client.api.client import (
         get_workflow_parameters,
         get_workflow_status,
         start_workflow,
     )
+    from reana_client.utils import get_api_url
 
     def display_status(workflow: str, current_status: str):
         """Display the current status of the workflow."""
@@ -586,12 +654,12 @@ def workflow_restart(
     \t $ reana-client restart -w myanalysis.42 -o TARGET=gendata\n
     \t $ reana-client restart -w myanalysis.42 -o FROM=fitdata
     """
-    from reana_client.utils import get_api_url
     from reana_client.api.client import (
         get_workflow_parameters,
         get_workflow_status,
         start_workflow,
     )
+    from reana_client.utils import get_api_url
 
     logging.debug("command: {}".format(ctx.command_path.replace(" ", ".")))
     for p in ctx.params:
@@ -1372,7 +1440,7 @@ def workflow_open_interactive_session(
     Examples:\n
     \t $ reana-client open -w myanalysis.42 jupyter
     """
-    from reana_client.api.client import open_interactive_session, info
+    from reana_client.api.client import info, open_interactive_session
 
     if workflow:
         try:
@@ -1457,3 +1525,189 @@ def workflow_close_interactive_session(workflow, access_token):  # noqa: D301
             sys.exit(1)
     else:
         display_message("Cannot find workflow {} ".format(workflow), msg_type="error")
+
+
+@workflow_sharing_group.command("share-add")
+@check_connection
+@add_workflow_option
+@add_access_token_options
+@click.option(
+    "-u",
+    "--user",
+    "users",
+    multiple=True,
+    help="Users to share the workflow with.",
+    required=True,
+)
+@click.option(
+    "-m",
+    "--message",
+    help="Optional message that is sent to the user(s) with the sharing invitation.",
+)
+@click.option(
+    "--valid-until",
+    type=click.DateTime(formats=["%Y-%m-%d"]),
+    help="Optional date when access to the workflow will expire for the given user(s) (format: YYYY-MM-DD).",
+)
+@click.pass_context
+def workflow_share_add(
+    ctx, workflow, access_token, users, message, valid_until
+):  # noqa D412
+    """Share a workflow with other users (read-only).
+
+    The `share-add` command allows sharing a workflow with other users. The
+    users will be able to view the workflow but not modify it.
+
+    Examples:
+
+    \t $ reana-client share-add -w myanalysis.42 --user bob@example.org
+
+    \t $ reana-client share-add -w myanalysis.42 --user bob@example.org --user cecile@example.org --message "Please review my analysis" --valid-until 2025-12-31
+    """
+    from reana_client.api.client import share_workflow
+
+    share_errors = []
+    shared_users = []
+
+    if valid_until:
+        valid_until = valid_until.strftime("%Y-%m-%d")
+
+    for user in users:
+        try:
+            logging.info(f"Sharing workflow {workflow} with user {user}")
+            share_workflow(
+                workflow,
+                user,
+                access_token,
+                message=message,
+                valid_until=valid_until,
+            )
+            shared_users.append(user)
+        except Exception as e:
+            share_errors.append(f"Failed to share {workflow} with {user}: {str(e)}")
+            logging.debug(traceback.format_exc())
+
+    if shared_users:
+        display_message(
+            f"{workflow} is now read-only shared with {', '.join(shared_users)}",
+            msg_type="success",
+        )
+
+    for error in share_errors:
+        display_message(error, msg_type="error")
+
+    if share_errors:
+        sys.exit(1)
+
+
+@workflow_sharing_group.command("share-remove")
+@check_connection
+@add_workflow_option
+@add_access_token_options
+@click.option(
+    "-u",
+    "--user",
+    "users",
+    multiple=True,
+    help="Users to unshare the workflow with.",
+    required=True,
+)
+@click.pass_context
+def share_workflow_remove(ctx, workflow, access_token, users):  # noqa D412
+    """Unshare a workflow.
+
+    The `share-remove` command allows for unsharing a workflow. The workflow
+    will no longer be visible to the users with whom it was shared.
+
+    Example:
+
+        $ reana-client share-remove -w myanalysis.42 --user bob@example.org
+    """
+    from reana_client.api.client import unshare_workflow
+
+    unshare_errors = []
+    unshared_users = []
+
+    for user in users:
+        try:
+            logging.info(f"Unsharing workflow {workflow} with user {user}")
+            unshare_workflow(workflow, user, access_token)
+            unshared_users.append(user)
+        except Exception as e:
+            unshare_errors.append(f"Failed to unshare {workflow} with {user}: {str(e)}")
+            logging.debug(traceback.format_exc())
+
+    if unshared_users:
+        display_message(
+            f"{workflow} is no longer shared with {', '.join(unshared_users)}",
+            msg_type="success",
+        )
+    if unshare_errors:
+        for error in unshare_errors:
+            display_message(error, msg_type="error")
+        sys.exit(1)
+
+
+@workflow_sharing_group.command("share-status")
+@check_connection
+@add_workflow_option
+@add_access_token_options
+@click.option(
+    "--format",
+    "format_",
+    multiple=True,
+    default=None,
+    help="Format output according to column titles or column "
+    "values. Use <columm_name>=<column_value> format.",
+)
+@click.option(
+    "--json",
+    "output_format",
+    flag_value="json",
+    default=None,
+    help="Get output in JSON format.",
+)
+@click.pass_context
+def share_workflow_status(
+    ctx, workflow, format_, output_format, access_token
+):  # noqa D412
+    """Show with whom a workflow is shared.
+
+    The `share-status` command allows for checking with whom a workflow is
+    shared.
+
+    Example:
+
+        $ reana-client share-status -w myanalysis.42
+    """
+    from reana_client.api.client import get_workflow_sharing_status
+
+    try:
+        sharing_status = get_workflow_sharing_status(workflow, access_token)
+    except Exception as e:
+        logging.debug(traceback.format_exc())
+        logging.debug(str(e))
+        display_message(
+            "An error occurred while checking workflow sharing status:\n{}".format(
+                str(e)
+            ),
+            msg_type="error",
+        )
+        sys.exit(1)
+
+    shared_with = sharing_status.get("shared_with", [])
+    if shared_with:
+        headers = ["user_email", "valid_until"]
+        data = [
+            [
+                entry["user_email"],
+                (entry["valid_until"] if entry["valid_until"] is not None else "-"),
+            ]
+            for entry in shared_with
+        ]
+
+        display_formatted_output(data, headers, format_, output_format)
+    else:
+        display_message(
+            f"Workflow {workflow} is not shared with anyone.", msg_type="info"
+        )
diff --git a/reana_client/config.py b/reana_client/config.py
index 78a27d6..3be4a48 100644
--- a/reana_client/config.py
+++ b/reana_client/config.py
@@ -11,9 +11,6 @@
 reana_yaml_valid_file_names = ["reana.yaml", "reana.yml"]
 """REANA specification valid file names."""
 
-default_user = "00000000-0000-0000-0000-000000000000"
-"""Default user to use when submitting workflows to REANA Server."""
-
 ERROR_MESSAGES = {
     "missing_access_token": "Please provide your access token by using"
     " the -t/--access-token flag, or by setting the"
diff --git a/setup.py b/setup.py
index 18603f8..37d7213 100644
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,9 @@ extras_require = {
         "sphinx-click>=1.0.4",
     ],
     "tests": [
-        "pytest-reana>=0.95.0a2,<0.96.0",
+        "pytest-reana>=0.95.0a4,<0.96.0",
+        "reana-commons[kubernetes]>=0.95.0a3,<0.96.0",
+        "reana-commons[kubernetes] @ git+https://github.com/reanahub/reana-commons.git@master",
     ],
 }
 
@@ -40,7 +42,8 @@ install_requires = [
     "click>=7",
     "pathspec==0.9.0",
     "jsonpointer>=2.0",
-    "reana-commons[yadage,snakemake,cwl]>=0.95.0a2,<0.96.0",
+    "reana-commons[yadage,snakemake,cwl]>=0.95.0a3,<0.96.0",
+    "reana-commons[yadage,snakemake,cwl] @ git+https://github.com/reanahub/reana-commons.git@master",
     "tablib>=0.12.1,<0.13",
     "werkzeug>=0.14.1 ; python_version<'3.10'",
     "werkzeug>=0.15.0 ; python_version>='3.10'",


Test patch:
diff --git a/tests/test_cli_workflows.py b/tests/test_cli_workflows.py
index 0c24464..2b3d652 100644
--- a/tests/test_cli_workflows.py
+++ b/tests/test_cli_workflows.py
@@ -17,12 +17,11 @@ import yaml
 from click.testing import CliRunner
 from mock import Mock, patch
 from pytest_reana.test_utils import make_mock_api_client
-from reana_commons.config import INTERACTIVE_SESSION_TYPES
-
 from reana_client.api.client import create_workflow_from_json
-from reana_client.config import RUN_STATUSES
 from reana_client.cli import cli
+from reana_client.config import RUN_STATUSES
 from reana_client.utils import get_workflow_status_change_msg
+from reana_commons.config import INTERACTIVE_SESSION_TYPES
 
 
 def test_workflows_server_not_connected():
@@ -488,6 +487,155 @@ def test_workflows_filter():
             assert "running" in json_response[0]["status"]
 
 
+def test_workflows_shared():
+    """Test workflow list command with --shared flag."""
+    response = {
+        "items": [
+            {
+                "status": "running",
+                "created": "2018-06-13T09:47:35.66097",
+                "user": "00000000-0000-0000-0000-000000000000",
+                "name": "mytest.1",
+                "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1",
+                "size": {"raw": 0, "human_readable": "0 Bytes"},
+                "progress": {},
+            }
+        ]
+    }
+    status_code = 200
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    env = {"REANA_SERVER_URL": "localhost"}
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(cli, ["list", "--shared", "-t", reana_token])
+            assert result.exit_code == 0
+            assert "SHARED_WITH" in result.output
+            assert "SHARED_BY" in result.output
+
+
+def test_workflows_shared_with():
+    """Test workflow list command with --shared-with flag."""
+    response = {
+        "items": [
+            {
+                "status": "running",
+                "created": "2018-06-13T09:47:35.66097",
+                "user": "00000000-0000-0000-0000-000000000000",
+                "name": "mytest.1",
+                "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1",
+                "size": {"raw": 0, "human_readable": "0 Bytes"},
+                "progress": {},
+            }
+        ]
+    }
+    status_code = 200
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    env = {"REANA_SERVER_URL": "localhost"}
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli, ["list", "--shared-with", "anybody", "-t", reana_token]
+            )
+            assert result.exit_code == 0
+            assert "SHARED_WITH" in result.output
+            assert "SHARED_BY" not in result.output
+
+
+def test_workflows_shared_by():
+    """Test workflow list command with --shared-by flag."""
+    response = {
+        "items": [
+            {
+                "status": "running",
+                "created": "2018-06-13T09:47:35.66097",
+                "user": "00000000-0000-0000-0000-000000000000",
+                "name": "mytest.1",
+                "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1",
+                "size": {"raw": 0, "human_readable": "0 Bytes"},
+                "progress": {},
+            }
+        ]
+    }
+    status_code = 200
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    env = {"REANA_SERVER_URL": "localhost"}
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli, ["list", "--shared-by", "anybody", "-t", reana_token]
+            )
+            assert result.exit_code == 0
+            assert "SHARED_WITH" not in result.output
+            assert "SHARED_BY" in result.output
+
+
+def test_workflows_shared_with_and_shared_by():
+    """Test workflow list command with --shared-with and --shared-by flags."""
+    response = {
+        "items": [
+            {
+                "status": "running",
+                "created": "2018-06-13T09:47:35.66097",
+                "user": "00000000-0000-0000-0000-000000000000",
+                "name": "mytest.1",
+                "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1",
+                "size": {"raw": 0, "human_readable": "0 Bytes"},
+                "progress": {},
+            }
+        ]
+    }
+    status_code = 200
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    env = {"REANA_SERVER_URL": "localhost"}
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli,
+                [
+                    "list",
+                    "--shared-with",
+                    "anybody",
+                    "--shared-by",
+                    "anybody",
+                    "-t",
+                    reana_token,
+                ],
+            )
+            assert result.exit_code == 1
+            assert (
+                "Please provide either --shared-by or --shared-with, not both"
+                in result.output
+            )
+
+
 def test_workflow_create_failed():
     """Test workflow create when creation fails."""
     env = {"REANA_SERVER_URL": "localhost"}
@@ -1011,3 +1159,110 @@ def test_run_with_no_inputs(spec_without_inputs):
         cli_workflow_start.assert_called()
         get_workflow_specification.assert_called()
         upload_to_server.assert_not_called()
+
+
+def test_share_add_workflow():
+    """Test share-add workflows."""
+    status_code = 200
+    response = {
+        "message": "is now read-only shared with",
+        "workflow_id": "string",
+        "workflow_name": "string",
+    }
+    env = {"REANA_SERVER_URL": "localhost"}
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli,
+                [
+                    "share-add",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "test-workflow.1",
+                    "--user",
+                    "bob@.cern.ch",
+                    "--message",
+                    "Test message",
+                    "--valid-until",
+                    "2024-01-01",
+                ],
+            )
+            assert result.exit_code == 0
+            assert response["message"] in result.output
+
+
+def test_share_remove_workflow():
+    """Test share-remove workflows."""
+    status_code = 200
+    response = {
+        "message": "is no longer shared with",
+        "workflow_id": "string",
+        "workflow_name": "string",
+    }
+    env = {"REANA_SERVER_URL": "localhost"}
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli,
+                [
+                    "share-remove",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "test-workflow.1",
+                    "--user",
+                    "bob@.cern.ch",
+                ],
+            )
+            assert result.exit_code == 0
+            assert response["message"] in result.output
+
+
+def test_share_status_workflow():
+    """Test share-status workflows."""
+    status_code = 200
+    response = {
+        "message": "is not shared with anyone",
+        "workflow_id": "string",
+        "workflow_name": "string",
+    }
+    env = {"REANA_SERVER_URL": "localhost"}
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli,
+                [
+                    "share-status",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "test-workflow.1",
+                ],
+            )
+            assert result.exit_code == 0
+            assert response["message"] in result.output