diff --git a/CHANGES/382.feature b/CHANGES/382.feature new file mode 100644 index 000000000..9283b3d8d --- /dev/null +++ b/CHANGES/382.feature @@ -0,0 +1 @@ +Added commands to manage roles. diff --git a/pulpcore/cli/common/generic.py b/pulpcore/cli/common/generic.py index 42711853e..a10c6c866 100644 --- a/pulpcore/cli/common/generic.py +++ b/pulpcore/cli/common/generic.py @@ -1,6 +1,7 @@ import gettext import json import re +from functools import lru_cache from typing import ( Any, Callable, @@ -42,6 +43,18 @@ class PulpCommand(click.Command): + def __init__(self, *args: Any, needs_plugins: Optional[List[PluginRequirement]] = None, **kwargs: Any): + self.needs_plugins = needs_plugins + super().__init__(*args, **kwargs) + + def invoke(self, ctx: click.Context) -> Any: + if self.needs_plugins: + pulp_ctx = ctx.find_object(PulpContext) + assert pulp_ctx is not None + for plugin_requirement in self.needs_plugins: + pulp_ctx.needs_plugin(plugin_requirement) + return super().invoke(ctx) + def get_short_help_str(self, limit: int = 45) -> str: return self.short_help or "" @@ -129,23 +142,31 @@ def handle_parse_result( # Option callbacks -def _href_callback( - ctx: click.Context, param: click.Parameter, value: Optional[str] -) -> Optional[str]: - if value is not None: - entity_ctx = ctx.find_object(PulpEntityContext) - assert entity_ctx is not None - entity_ctx.pulp_href = value - return value +@lru_cache(typed=True) +def lookup_callback( + attribute: str, ContextClass: Type[PulpEntityContext] = PulpEntityContext +) -> Callable[[click.Context, click.Parameter, Optional[str]], Optional[str]]: + def _callback( + ctx: click.Context, param: click.Parameter, value: Optional[str] + ) -> Optional[str]: + if value is not None: + if value == "": + value = "null" + entity_ctx = ctx.find_object(ContextClass) + assert entity_ctx is not None + entity_ctx.entity = {attribute: value} + return value + + return _callback -def _name_callback( +def _href_callback( ctx: click.Context, param: click.Parameter, value: Optional[str] ) -> Optional[str]: if value is not None: entity_ctx = ctx.find_object(PulpEntityContext) assert entity_ctx is not None - entity_ctx.entity = {"name": value} + entity_ctx.pulp_href = value return value @@ -159,16 +180,6 @@ def _repository_href_callback( return value -def _repository_callback( - ctx: click.Context, param: click.Parameter, value: Optional[str] -) -> Optional[str]: - if value is not None: - repository_ctx = ctx.find_object(PulpRepositoryContext) - assert repository_ctx is not None - repository_ctx.entity = {"name": value} - return value - - def _version_callback( ctx: click.Context, param: click.Parameter, value: Optional[int] ) -> Optional[int]: @@ -254,6 +265,14 @@ def parse_size_callback(ctx: click.Context, param: click.Parameter, value: str) return int(float(number) * units[unit]) +def null_callback( + ctx: click.Context, param: click.Parameter, value: Optional[str] +) -> Optional[str]: + if value == "": + return "null" + return value + + ############################################################################## # Decorator common options @@ -387,7 +406,7 @@ def _multi_option_callback( name_option = pulp_option( "--name", help=_("Name of the {entity}"), - callback=_name_callback, + callback=lookup_callback("name"), expose_value=False, ) @@ -401,7 +420,7 @@ def _multi_option_callback( repository_option = click.option( "--repository", help=_("Name of the repository"), - callback=_repository_callback, + callback=lookup_callback("name", PulpRepositoryContext), expose_value=False, ) diff --git a/pulpcore/cli/common/openapi.py b/pulpcore/cli/common/openapi.py index 5fa0ad39c..ee5c1eb05 100644 --- a/pulpcore/cli/common/openapi.py +++ b/pulpcore/cli/common/openapi.py @@ -255,7 +255,7 @@ def call( self.debug_callback(1, f"{method} {request.url}") for key, value in request.headers.items(): self.debug_callback(2, f" {key}: {value}") - if request.body: + if request.body is not None: self.debug_callback(2, f"{request.body!r}") if self.safe_calls_only and method.upper() not in SAFE_METHODS: raise OpenAPIError(_("Call aborted due to safe mode")) diff --git a/pulpcore/cli/core/__init__.py b/pulpcore/cli/core/__init__.py index e8ae21040..a8e51b88e 100644 --- a/pulpcore/cli/core/__init__.py +++ b/pulpcore/cli/core/__init__.py @@ -12,6 +12,7 @@ from pulpcore.cli.core.orphan import orphan from pulpcore.cli.core.orphans import orphans from pulpcore.cli.core.repository import repository +from pulpcore.cli.core.role import role from pulpcore.cli.core.show import show from pulpcore.cli.core.signing_service import signing_service from pulpcore.cli.core.status import status @@ -34,6 +35,7 @@ main.add_command(orphan) main.add_command(orphans) # This one is deprecated main.add_command(repository) +main.add_command(role) main.add_command(show) main.add_command(signing_service) main.add_command(status) diff --git a/pulpcore/cli/core/context.py b/pulpcore/cli/core/context.py index 32ab3fda6..17e365c28 100644 --- a/pulpcore/cli/core/context.py +++ b/pulpcore/cli/core/context.py @@ -172,6 +172,26 @@ class PulpGroupObjectPermissionContext(PulpGroupPermissionContext): DELETE_ID = "groups_object_permissions_delete" +class PulpGroupRoleContext(PulpEntityContext): + ENTITY = _("group role") + ENTITIES = _("group roles") + HREF = "auth_groups_group_role_href" + LIST_ID = "groups_roles_list" + READ_ID = "groups_roles_read" + CREATE_ID = "groups_roles_create" + DELETE_ID = "groups_roles_delete" + NULLABLES = {"content_object"} + group_ctx: PulpGroupContext + + def __init__(self, pulp_ctx: PulpContext, group_ctx: PulpGroupContext) -> None: + super().__init__(pulp_ctx) + self.group_ctx = group_ctx + + @property + def scope(self) -> Dict[str, Any]: + return {PulpGroupContext.HREF: self.group_ctx.pulp_href} + + class PulpGroupUserContext(PulpEntityContext): ENTITY = _("group user") ENTITIES = _("group users") @@ -229,6 +249,17 @@ def remove(self, href: str, users: Optional[List[str]], groups: Optional[List[st return self.pulp_ctx.call(self.REMOVE_ID, parameters={self.HREF: href}, body=body) +class PulpRoleContext(PulpEntityContext): + ENTITY = _("role") + ENTITIES = _("roles") + HREF = "role_href" + LIST_ID = "roles_list" + READ_ID = "roles_read" + CREATE_ID = "roles_create" + UPDATE_ID = "roles_partial_update" + DELETE_ID = "roles_delete" + + class PulpSigningServiceContext(PulpEntityContext): ENTITY = _("signing service") ENTITIES = _("signing services") @@ -296,6 +327,29 @@ class PulpUserContext(PulpEntityContext): HREF = "auth_user_href" LIST_ID = "users_list" READ_ID = "users_read" + CREATE_ID = "users_create" + UPDATE_ID = "users_partial_update" + DELETE_ID = "users_delete" + + +class PulpUserRoleContext(PulpEntityContext): + ENTITY = _("user role") + ENTITIES = _("user roles") + HREF = "auth_users_user_role_href" + LIST_ID = "users_roles_list" + READ_ID = "users_roles_read" + CREATE_ID = "users_roles_create" + DELETE_ID = "users_roles_delete" + NULLABLES = {"content_object"} + user_ctx: PulpUserContext + + def __init__(self, pulp_ctx: PulpContext, user_ctx: PulpUserContext) -> None: + super().__init__(pulp_ctx) + self.user_ctx = user_ctx + + @property + def scope(self) -> Dict[str, Any]: + return {PulpUserContext.HREF: self.user_ctx.pulp_href} class PulpWorkerContext(PulpEntityContext): diff --git a/pulpcore/cli/core/group.py b/pulpcore/cli/core/group.py index 8c8fa5414..79767dbae 100644 --- a/pulpcore/cli/core/group.py +++ b/pulpcore/cli/core/group.py @@ -3,39 +3,28 @@ import click -from pulpcore.cli.common.context import PulpContext, pass_entity_context, pass_pulp_context +from pulpcore.cli.common.context import PulpContext, pass_entity_context, pass_pulp_context, PluginRequirement from pulpcore.cli.common.generic import ( create_command, destroy_command, href_option, list_command, + lookup_callback, name_option, show_command, + null_callback ) from pulpcore.cli.core.context import ( PulpGroupContext, PulpGroupModelPermissionContext, PulpGroupObjectPermissionContext, PulpGroupPermissionContext, + PulpGroupRoleContext, PulpGroupUserContext, PulpUserContext, ) - -def _groupname_callback(ctx: click.Context, param: click.Parameter, value: str) -> str: - if value is not None: - entity_ctx = ctx.find_object(PulpGroupContext) - assert entity_ctx is not None - entity_ctx.entity = {"name": value} - return value - - -def _permission_callback(ctx: click.Context, param: click.Parameter, value: str) -> str: - if value is not None: - entity_ctx = ctx.find_object(PulpGroupPermissionContext) - assert entity_ctx is not None - entity_ctx.entity = {"permission": value} - return value +_ = gettext.gettext def _object_callback(ctx: click.Context, param: click.Parameter, value: str) -> str: @@ -51,9 +40,9 @@ def _object_callback(ctx: click.Context, param: click.Parameter, value: str) -> return value -groupname_option = click.option("--groupname", callback=_groupname_callback, expose_value=False) - -_ = gettext.gettext +group_option = click.option( + "--group", callback=lookup_callback("name", PulpGroupContext), expose_value=False +) @click.group(help=_("Manage user groups and their granted permissions.")) @@ -99,13 +88,13 @@ def permission( permission.add_command( list_command( - help=_("Show a list of the permissioons granted to a group."), decorators=[groupname_option] + help=_("Show a list of the permissioons granted to a group."), decorators=[group_option] ) ) @permission.command(name="add", help=_("Grant a permission to the group.")) -@groupname_option +@group_option @click.option("--permission", required=True) @click.option("--object", "obj", callback=_object_callback) @pass_entity_context @@ -123,9 +112,12 @@ def add_permission( name="remove", help=_("Revoke a permission from the group."), decorators=[ - groupname_option, + group_option, click.option( - "--permission", required=True, callback=_permission_callback, expose_value=False + "--permission", + required=True, + callback=lookup_callback("permission", PulpGroupPermissionContext), + expose_value=False, ), click.option("--object", callback=_object_callback, expose_value=False), ], @@ -141,19 +133,15 @@ def user(ctx: click.Context, pulp_ctx: PulpContext, group_ctx: PulpGroupContext) ctx.obj = PulpGroupUserContext(pulp_ctx, group_ctx) -user.add_command(list_command(decorators=[groupname_option])) - - -@user.command(name="add") -@groupname_option -@click.option("--username", required=True) -@pass_entity_context -def add_user(entity_ctx: PulpGroupUserContext, username: str) -> None: - entity_ctx.create(body={"username": username}) +user.add_command(list_command(decorators=[group_option])) +user.add_command( + create_command(decorators=[group_option, click.option("--username", required=True)]), + name="add", +) @user.command(name="remove") -@groupname_option +@group_option @click.option("--username", required=True) @pass_entity_context @pass_pulp_context @@ -162,3 +150,57 @@ def remove_user(pulp_ctx: PulpContext, entity_ctx: PulpGroupUserContext, usernam user_pk = user_href.split("/")[-2] group_user_href = f"{entity_ctx.group_ctx.pulp_href}users/{user_pk}/" entity_ctx.delete(group_user_href) + + +@group.group() +@pass_entity_context +@pass_pulp_context +@click.pass_context +def role(ctx: click.Context, pulp_ctx: PulpContext, group_ctx: PulpGroupContext) -> None: + pulp_ctx.needs_plugin(PluginRequirement("core", min="3.17.dev")) + ctx.obj = PulpGroupRoleContext(pulp_ctx, group_ctx) + + +role.add_command( + list_command( + decorators=[ + group_option, + click.option("--role"), + click.option("--role-in", "role__in"), + click.option("--role-contains", "role__contains"), + click.option("--role-icontains", "role__icontains"), + click.option("--role-startswith", "role__startswith"), + click.option("--content-object", callback=null_callback), + ] + ) +) +role.add_command( + create_command( + decorators=[ + group_option, + click.option("--role", required=True), + click.option("--content-object", required=True), + ] + ), + name="assign", +) +role.add_command( + destroy_command( + decorators=[ + group_option, + click.option( + "--role", + required=True, + callback=lookup_callback("role", PulpGroupRoleContext), + expose_value=False, + ), + click.option( + "--content-object", + required=True, + callback=lookup_callback("content_object", PulpGroupRoleContext), + expose_value=False, + ), + ] + ), + name="remove", +) diff --git a/pulpcore/cli/core/role.py b/pulpcore/cli/core/role.py new file mode 100644 index 000000000..36a670522 --- /dev/null +++ b/pulpcore/cli/core/role.py @@ -0,0 +1,79 @@ +import gettext +from typing import Iterable, Optional + +import click + +from pulpcore.cli.common.context import PluginRequirement, PulpContext, pass_pulp_context +from pulpcore.cli.common.generic import ( + create_command, + destroy_command, + href_option, + list_command, + name_option, + show_command, + update_command, +) +from pulpcore.cli.core.context import PulpRoleContext + +_ = gettext.gettext +NO_PERMISSION_KEY = "pulpcore.cli.core.role.no_permission" + + +def _no_permission_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool: + ctx.meta[NO_PERMISSION_KEY] = value + return value + + +def _permission_callback( + ctx: click.Context, param: click.Parameter, value: Iterable[str] +) -> Optional[Iterable[str]]: + if ctx.meta.get(NO_PERMISSION_KEY, False): + if value: + raise click.ClickException(_("Cannot specify `--permission` and `--no-permission`.")) + return [] + return value or None + + +filters = [ + click.option("--locked/--unlocked", default=None), + click.option("--name"), + click.option("--name-in", "name__in"), + click.option("--name-contains", "name__contains"), + click.option("--name-icontains", "name__icontains"), + click.option("--name-startswith", "name__startswith"), +] +lookup_options = [href_option, name_option] +update_options = [ + click.option( + "--no-permission", + is_eager=True, + is_flag=True, + expose_value=False, + callback=_no_permission_callback, + ), + click.option( + "--permission", + "permissions", + multiple=True, + help=_("Permission in the form '.'. Can be used multiple times."), + callback=_permission_callback, + ), +] +create_options = [ + click.option("--name", required=True, help=_("Name of the role")), +] + update_options + + +@click.group() +@pass_pulp_context +@click.pass_context +def role(ctx: click.Context, pulp_ctx: PulpContext) -> None: + pulp_ctx.needs_plugin(PluginRequirement("core", min="3.16.dev")) + ctx.obj = PulpRoleContext(pulp_ctx) + + +role.add_command(list_command(decorators=filters)) +role.add_command(show_command(decorators=lookup_options)) +role.add_command(create_command(decorators=create_options)) +role.add_command(update_command(decorators=lookup_options + update_options)) +role.add_command(destroy_command(decorators=lookup_options)) diff --git a/pulpcore/cli/core/user.py b/pulpcore/cli/core/user.py index 18cad001f..b2ab0e6dd 100644 --- a/pulpcore/cli/core/user.py +++ b/pulpcore/cli/core/user.py @@ -1,19 +1,52 @@ import gettext +from typing import Optional import click -from pulpcore.cli.common.context import ( +from pulpcore.cli.common.context import ( # PulpEntityContext,; pass_entity_context, + PluginRequirement, PulpContext, - PulpEntityContext, pass_entity_context, pass_pulp_context, ) -from pulpcore.cli.common.generic import list_command -from pulpcore.cli.core.context import PulpUserContext +from pulpcore.cli.common.generic import ( + create_command, + update_command, + destroy_command, + href_option, + list_command, + lookup_callback, + pulp_option, + show_command, + null_callback +) +from pulpcore.cli.core.context import PulpUserContext, PulpUserRoleContext _ = gettext.gettext +username_option = pulp_option( + "--username", + help=_("Username of the {entity}"), + expose_value=False, + callback=lookup_callback("username", PulpUserContext), +) +lookup_options = [ + href_option, + username_option, +] +update_options = [ + click.option("--first-name"), + click.option("--last-name"), + click.option("--email"), + click.option("--staff/--no-staff", "is_staff", default=None), + click.option("--active/--inactive", "is_active", default=None), +] +create_options = update_options + [ + click.option("--username", required=True), +] + + @click.group() @pass_pulp_context @click.pass_context @@ -22,14 +55,62 @@ def user(ctx: click.Context, pulp_ctx: PulpContext) -> None: user.add_command(list_command()) +user.add_command(show_command(decorators=lookup_options)) +needs_plugins=[PluginRequirement("core", min="3.17.dev")] +user.add_command(create_command(decorators=create_options, needs_plugins=needs_plugins)) +user.add_command(update_command(decorators=lookup_options + update_options, needs_plugins=needs_plugins)) +user.add_command(destroy_command(decorators=lookup_options, needs_plugins=needs_plugins)) -@user.command() -@click.option("--username", required=True, help=_("Username of the entry")) +@user.group() @pass_entity_context @pass_pulp_context -def show(pulp_ctx: PulpContext, entity_ctx: PulpEntityContext, username: str) -> None: - """Shows details of an entry""" - href = entity_ctx.find(username=username)["pulp_href"] - entity = entity_ctx.show(href) - pulp_ctx.output_result(entity) +@click.pass_context +def role(ctx: click.Context, pulp_ctx: PulpContext, user_ctx: PulpUserContext) -> None: + pulp_ctx.needs_plugin(PluginRequirement("core", min="3.17.dev")) + ctx.obj = PulpUserRoleContext(pulp_ctx, user_ctx) + + +role.add_command( + list_command( + decorators=[ + username_option, + click.option("--role"), + click.option("--role-in", "role__in"), + click.option("--role-contains", "role__contains"), + click.option("--role-icontains", "role__icontains"), + click.option("--role-startswith", "role__startswith"), + click.option("--content-object", callback=null_callback), + ] + ) +) +role.add_command( + create_command( + decorators=[ + username_option, + click.option("--role", required=True), + click.option("--content-object", required=True), + ] + ), + name="assign", +) +role.add_command( + destroy_command( + decorators=[ + username_option, + click.option( + "--role", + required=True, + callback=lookup_callback("role", PulpUserRoleContext), + expose_value=False, + ), + click.option( + "--content-object", + required=True, + callback=lookup_callback("content_object", PulpUserRoleContext), + expose_value=False, + ), + ] + ), + name="remove", +) diff --git a/tests/scripts/pulpcore/test_group.sh b/tests/scripts/pulpcore/test_group.sh index d8f4e225e..1296a4aaa 100755 --- a/tests/scripts/pulpcore/test_group.sh +++ b/tests/scripts/pulpcore/test_group.sh @@ -13,8 +13,8 @@ expect_succ pulp group create --name "cli_test_group" expect_succ pulp group show --name "cli_test_group" expect_succ pulp group list -expect_succ pulp group user add --groupname "cli_test_group" --username "admin" -expect_succ pulp group user list --groupname "cli_test_group" -expect_succ pulp group user remove --groupname "cli_test_group" --username "admin" +expect_succ pulp group user add --group "cli_test_group" --username "admin" +expect_succ pulp group user list --group "cli_test_group" +expect_succ pulp group user remove --group "cli_test_group" --username "admin" expect_succ pulp group destroy --name "cli_test_group" diff --git a/tests/scripts/pulpcore/test_group_permissions.sh b/tests/scripts/pulpcore/test_group_permissions.sh index 5256f0049..9f5977a6a 100755 --- a/tests/scripts/pulpcore/test_group_permissions.sh +++ b/tests/scripts/pulpcore/test_group_permissions.sh @@ -13,13 +13,13 @@ trap cleanup EXIT expect_succ pulp group create --name "cli_test_group" -expect_succ pulp group permission add --groupname "cli_test_group" --permission "core.view_task" -expect_succ pulp group permission add --groupname "cli_test_group" --permission "auth.view_group" -expect_succ pulp group permission list --groupname "cli_test_group" -expect_succ pulp group permission remove --groupname "cli_test_group" --permission "core.view_task" +expect_succ pulp group permission add --group "cli_test_group" --permission "core.view_task" +expect_succ pulp group permission add --group "cli_test_group" --permission "auth.view_group" +expect_succ pulp group permission list --group "cli_test_group" +expect_succ pulp group permission remove --group "cli_test_group" --permission "core.view_task" expect_succ pulp file repository create --name "cli_group_test_repository" REPO_HREF="$(echo "$OUTPUT" | jq -r '.pulp_href')" -expect_succ pulp group permission -t object add --groupname "cli_test_group" --permission "file.view_filerepository" --object "$REPO_HREF" -expect_succ pulp group permission -t object list --groupname "cli_test_group" -expect_succ pulp group permission -t object remove --groupname "cli_test_group" --permission "file.view_filerepository" --object "$REPO_HREF" +expect_succ pulp group permission -t object add --group "cli_test_group" --permission "file.view_filerepository" --object "$REPO_HREF" +expect_succ pulp group permission -t object list --group "cli_test_group" +expect_succ pulp group permission -t object remove --group "cli_test_group" --permission "file.view_filerepository" --object "$REPO_HREF" diff --git a/tests/scripts/pulpcore/test_user.sh b/tests/scripts/pulpcore/test_user.sh new file mode 100755 index 000000000..de032b2f7 --- /dev/null +++ b/tests/scripts/pulpcore/test_user.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# shellcheck source=tests/scripts/config.source +. "$(dirname "$(dirname "$(realpath "$0")")")"/config.source + +cleanup() { + true +} +trap cleanup EXIT + +expect_succ pulp user list +expect_succ pulp user show --username admin