feat(connectors): named permission-sync and group-sync checks for Jira (#15772)

This commit is contained in:
Justin Tahara authored and GitHub committed 2026-10-09 20:33:13 +00:00
1 parent ad351d36ef
commit b912cceb4e
9 files changed
+1211 -33

No files matched your search

@@ -20,6 +20,10 @@ from onyx.connectors.confluence.capability_checks import (
build_confluence_doc_permission_sync_checks,
build_confluence_group_sync_checks,
)
from onyx.connectors.jira.capability_checks import (
build_jira_doc_permission_sync_checks,
build_jira_group_sync_checks,
)
from onyx.connectors.onedrive.capability_checks import (
build_onedrive_doc_permission_sync_checks,
build_onedrive_group_sync_checks,
@@ -39,6 +43,7 @@ _DOC_PERMISSION_SYNC_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck
DocumentSource.CONFLUENCE: build_confluence_doc_permission_sync_checks(),
DocumentSource.OUTLOOK: build_outlook_doc_permission_sync_checks(),
DocumentSource.ONEDRIVE: build_onedrive_doc_permission_sync_checks(),
DocumentSource.JIRA: build_jira_doc_permission_sync_checks(),
}
# Slack registers nothing here by design: it has no group sync (channel access
@@ -47,6 +52,7 @@ _DOC_PERMISSION_SYNC_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck
_EXTERNAL_GROUP_SYNC_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {
DocumentSource.ONEDRIVE: build_onedrive_group_sync_checks(),
DocumentSource.CONFLUENCE: build_confluence_group_sync_checks(),
DocumentSource.JIRA: build_jira_group_sync_checks(),
}
@@ -12,6 +12,17 @@ from onyx.connectors.jira.source_operations import (
JiraSourceOperations,
is_cloud_gateway,
)
from onyx.connectors.jira.utils import (
ATLASSIAN_GROUP_ROLE_ACTOR_TYPE,
ATLASSIAN_USER_ROLE_ACTOR_TYPE,
BROWSE_PROJECTS_PERMISSION,
HOLDER_TYPE_ANYONE,
HOLDER_TYPE_APPLICATION_ROLE,
HOLDER_TYPE_GROUP,
HOLDER_TYPE_PROJECT_ROLE,
HOLDER_TYPE_USER,
SUPPORTED_STATIC_HOLDER_TYPES,
)
from onyx.utils.logger import setup_logger
HolderMap = dict[str, list[Holder]]
@@ -19,26 +30,6 @@ HolderMap = dict[str, list[Holder]]
logger = setup_logger()
BROWSE_PROJECTS_PERMISSION = "BROWSE_PROJECTS"
HOLDER_TYPE_ANYONE = "anyone"
HOLDER_TYPE_APPLICATION_ROLE = "applicationRole"
HOLDER_TYPE_USER = "user"
HOLDER_TYPE_PROJECT_ROLE = "projectRole"
HOLDER_TYPE_GROUP = "group"
SUPPORTED_STATIC_HOLDER_TYPES = {
HOLDER_TYPE_ANYONE,
HOLDER_TYPE_APPLICATION_ROLE,
HOLDER_TYPE_USER,
HOLDER_TYPE_PROJECT_ROLE,
HOLDER_TYPE_GROUP,
}
# Jira DC/Server returns project-role actors flat with this `type` discriminator;
# Jira Cloud v3 instead wraps them in nested `actorGroup` / `actorUser` objects.
ATLASSIAN_GROUP_ROLE_ACTOR_TYPE = "atlassian-group-role-actor"
ATLASSIAN_USER_ROLE_ACTOR_TYPE = "atlassian-user-role-actor"
class _RoleActorCategory(StrEnum):
GROUP = "group"
@@ -1,6 +1,8 @@
"""Capability checks for the Jira connector.
The indexing checks register in ``registry.py``. Each check makes small probes
The indexing checks register in ``registry.py``; the permission-sync and
group-sync checks register in the EE registry
(``ee/onyx/connectors/capability_checks.py``). Each check makes small probes
(one issue, one project) with the gateway operations that its sync calls.
The gateway reaches the site through the bound config fields
@@ -36,12 +38,25 @@ from onyx.connectors.exceptions import (
)
from onyx.connectors.jira.config import JiraConnectorConfig
from onyx.connectors.jira.connector import build_jql_query, jira_error_messages
from onyx.connectors.jira.models import JiraIssueIdPage
from onyx.connectors.jira.models import JiraGroupMemberSample, JiraIssueIdPage
from onyx.connectors.jira.source_operations import (
JiraApiError,
JiraGroupPage,
JiraSourceOperations,
is_cloud_gateway,
)
from onyx.connectors.jira.utils import (
ATLASSIAN_GROUP_ROLE_ACTOR_TYPE,
ATLASSIAN_USER_ROLE_ACTOR_TYPE,
BROWSE_PROJECTS_PERMISSION,
HOLDER_TYPE_ANYONE,
HOLDER_TYPE_APPLICATION_ROLE,
HOLDER_TYPE_GROUP,
HOLDER_TYPE_PROJECT_ROLE,
HOLDER_TYPE_USER,
SUPPORTED_STATIC_HOLDER_TYPES,
get_issue_field,
)
from onyx.connectors.source_operations import SourceOperations
_DOCS_LINK = "https://docs.onyx.app/admins/connectors/official/jira"
@@ -496,7 +511,7 @@ class _IssueReadCheck(_JiraCheck):
"The indexing scope has no issue that this credential can read, "
f"so Onyx cannot verify that issues are readable. {hint}"
)
fields: object = issue.get("fields")
fields: Any = issue.get("fields")
if not isinstance(fields, dict) or "summary" not in fields:
raise InsufficientPermissionsError(
"Jira found an issue but did not return its fields, so issues "
@@ -513,3 +528,605 @@ def build_jira_indexing_checks() -> list[CapabilityCheck]:
_JqlQueryCheck(),
_IssueReadCheck(),
]
# Permission sync. The EE registry registers these checks. Doc sync reads the
# Browse Projects grants of each project's permission scheme, the actors of its
# project roles, and the email of each user. Group sync lists the groups and
# the members of each group. Both map Jira users to Onyx users by email.
_PERMISSION_SYNC_SCOPES = "`read:jira-user` and `manage:jira-configuration`"
_PUBLIC_HOLDER_TYPES = frozenset({HOLDER_TYPE_ANYONE, HOLDER_TYPE_APPLICATION_ROLE})
_SAMPLE_USERS = 5
_SAMPLE_ROLES = 3
_SAMPLE_GROUPS = 3
_GROUP_MEMBER_PAGE_SIZE = 50
_ATLASSIAN_ACCOUNT_TYPE = "atlassian"
_ADMIN_HINT = (
"Give the token's account Administer Jira, or Administer Projects on every "
"project to sync (the Administrator role in team-managed projects)."
)
_CLOUD_EMAIL_HINT = (
"Atlassian returns a user's email only when the user's profile shows it "
"(Profile and visibility > Contact), or when the organization makes emails "
"visible to apps. Users without a visible email get no access in Onyx."
)
_DC_EMAIL_HINT = (
"Jira Data Center returns emails only when 'User email visibility' "
"(General configuration) is 'Public', or 'Only visible to logged-in users' "
"with a token account that can see them."
)
_GROUP_ACCESS_HINT = (
"Give the token's account Administer Jira, or the global Browse users and "
"groups permission. On Data Center the account must be in a Jira "
"administrators group."
)
def _perm_sync_hint(config: JiraConnectorConfig, admin_hint: str) -> str:
if config.scoped_token:
return (
f"The scoped API token needs the {_PERMISSION_SYNC_SCOPES} scopes, and "
f"its account needs admin access. {admin_hint}"
)
return admin_hint
def _email_hint(gateway: JiraSourceOperations) -> str:
return _CLOUD_EMAIL_HINT if is_cloud_gateway(gateway) else _DC_EMAIL_HINT
def _probe_project_key(
context: CapabilityCheckContext, config: JiraConnectorConfig
) -> str:
"""The configured project in project mode, the project of the first
matching issue in JQL mode, else the first visible project. Permission sync
reads the scheme of every project with indexed issues, so a JQL query that
matches nothing has no project to probe."""
project_key: str = _project_key(config)
if project_key and not _jql(config):
return project_key
if _jql(config):
try:
issue = _search_one_issue(_gateway(context), _scope_jql(config))
except JiraApiError as e:
raise UnexpectedValidationError(
"Onyx cannot run the JQL query, so it cannot pick a project to "
"probe. jira_jql_query reports why."
) from e
project: Any = get_issue_field(issue, "project") if issue else None
if not isinstance(project, dict) or not project.get("key"):
raise UnexpectedValidationError(
"The JQL query matches no issue, so permission sync reads no "
"project yet and Onyx cannot verify it."
)
return str(project["key"])
try:
projects: list[dict[str, Any]] = _gateway(context).list_projects()
except JiraApiError as e:
_raise_for_api_error(e, denied="The credential cannot list Jira projects")
if not projects:
raise UnexpectedValidationError(
"No Jira project is visible, so Onyx cannot read a permission scheme. "
"jira_projects_visible reports why."
)
return str(projects[0]["key"])
def _browse_holders(scheme: dict[str, Any]) -> list[dict[str, Any]]:
return [
permission["holder"]
for permission in scheme.get("permissions") or []
if isinstance(permission, dict)
and permission.get("permission") == BROWSE_PROJECTS_PERMISSION
and isinstance(permission.get("holder"), dict)
]
def _role_id(holder: dict[str, Any]) -> str | None:
role_id: Any = holder.get("value") or holder.get("parameter")
return str(role_id) if role_id else None
def _read_permission_scheme(
context: CapabilityCheckContext, config: JiraConnectorConfig, project_key: str
) -> dict[str, Any]:
try:
scheme: dict[str, Any] = _gateway(context).get_project_permission_scheme(
project_key=project_key
)
except JiraApiError as e:
_raise_for_api_error(
e,
denied=(
"The credential cannot read the permission scheme of the project "
f"`{project_key}`. {_perm_sync_hint(config, _ADMIN_HINT)}"
),
not_found=(
f"Jira does not return the permission scheme of the project "
f"`{project_key}` to this credential. "
f"{_perm_sync_hint(config, _ADMIN_HINT)}"
),
)
if not isinstance(scheme.get("permissions"), list):
raise InsufficientPermissionsError(
f"Jira returned the permission scheme of the project `{project_key}` "
f"without its grants. {_perm_sync_hint(config, _ADMIN_HINT)}"
)
return scheme
def _read_scheme_for_dependent_check(
context: CapabilityCheckContext, config: JiraConnectorConfig
) -> tuple[str, dict[str, Any]]:
"""The probe project and its scheme, for a check that needs the scheme
first. A failed read is jira_permission_scheme_read's failure."""
try:
project_key = _probe_project_key(context, config)
return project_key, _read_permission_scheme(context, config, project_key)
except ConnectorValidationError as e:
raise UnexpectedValidationError(
"Onyx cannot read the permission scheme, so this check cannot run. "
"jira_permission_scheme_read reports why."
) from e
class _PermissionSchemeReadCheck(_JiraCheck):
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.DOC_PERMISSION_SYNC,
check_id="jira_permission_scheme_read",
display_name="Project permission schemes are readable",
remediation=(
f"{_ADMIN_HINT} Scoped API token: the {_PERMISSION_SYNC_SCOPES} scopes."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
project_key = _probe_project_key(context, config)
scheme = _read_permission_scheme(context, config, project_key)
if not _browse_holders(scheme):
raise InsufficientPermissionsError(
f"The permission scheme of the project `{project_key}` grants "
"Browse Projects to nobody that Jira shows to this credential. "
f"{_perm_sync_hint(config, _ADMIN_HINT)}"
)
class _ProjectAccessMappableCheck(_JiraCheck):
"""Warns when Browse Projects is granted only through holders that have no
static Onyx equivalent (Reporter, Assignee, Project Lead, user fields)."""
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.DOC_PERMISSION_SYNC,
check_id="jira_project_access_mappable",
display_name="Project access maps to Onyx",
required=False,
remediation=(
"Grant Browse Projects to users, groups or project roles. Onyx "
"cannot map Reporter, Assignee, Project Lead or user-field grants, "
"so those users get no access in Onyx."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
project_key, scheme = _read_scheme_for_dependent_check(context, config)
holder_types: set[str] = {
str(holder.get("type")) for holder in _browse_holders(scheme)
}
unsupported: list[str] = sorted(holder_types - SUPPORTED_STATIC_HOLDER_TYPES)
if unsupported:
raise ConnectorValidationError(
f"The project `{project_key}` grants Browse Projects through "
f"{', '.join(unsupported)}. Onyx cannot map these grants, so the "
"users they cover get no access to the project's issues in Onyx."
)
def _role_actor_user(actor: dict[str, Any]) -> dict[str, Any] | None:
"""The user of a role actor: nested ``actorUser`` on Cloud, the flat actor
on Data Center."""
actor_user: Any = actor.get("actorUser")
if isinstance(actor_user, dict):
return actor_user
if actor.get("type") == ATLASSIAN_USER_ROLE_ACTOR_TYPE:
return actor
return None
def _user_lookup_id(gateway: JiraSourceOperations, user: dict[str, Any]) -> str | None:
fields: tuple[str, ...] = (
("accountId", "name", "key")
if is_cloud_gateway(gateway)
else (
"name",
"key",
"accountId",
)
)
for field in fields:
value: Any = user.get(field)
if isinstance(value, str) and value:
return value
return None
def _browse_role_ids(scheme: dict[str, Any]) -> list[str]:
return [
role_id
for holder in _browse_holders(scheme)
if holder.get("type") == HOLDER_TYPE_PROJECT_ROLE
and (role_id := _role_id(holder))
]
def _read_role(
context: CapabilityCheckContext,
config: JiraConnectorConfig,
project_key: str,
role_id: str,
) -> dict[str, Any]:
"""Reads one project role. Raises the validation family on a failed read."""
try:
return _gateway(context).get_project_role(
project_key=project_key, role_id=role_id
)
except JiraApiError as e:
_raise_for_api_error(
e,
denied=(
f"The credential cannot read the roles of the project "
f"`{project_key}`. {_perm_sync_hint(config, _ADMIN_HINT)}"
),
not_found=(
f"Jira does not return the role {role_id} of the project "
f"`{project_key}` to this credential. "
f"{_perm_sync_hint(config, _ADMIN_HINT)}"
),
)
def _is_public(scheme: dict[str, Any]) -> bool:
"""True when anyone or every licensed user can browse the project.
Permission sync then returns public access without reading roles or
users."""
return any(
holder.get("type") in _PUBLIC_HOLDER_TYPES for holder in _browse_holders(scheme)
)
class _ProjectRolesReadCheck(_JiraCheck):
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.DOC_PERMISSION_SYNC,
check_id="jira_project_roles_read",
display_name="Project roles are readable",
remediation=(
f"{_ADMIN_HINT} Scoped API token: the {_PERMISSION_SYNC_SCOPES} scopes."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
project_key, scheme = _read_scheme_for_dependent_check(context, config)
role_ids: list[str] = _browse_role_ids(scheme)
if _is_public(scheme) or not role_ids:
return
role: dict[str, Any] = _read_role(context, config, project_key, role_ids[0])
if not isinstance(role.get("actors"), list):
raise InsufficientPermissionsError(
f"Jira returned a role of the project `{project_key}` without its "
f"members. {_perm_sync_hint(config, _ADMIN_HINT)}"
)
def _is_group_actor(actor: dict[str, Any]) -> bool:
return (
isinstance(actor.get("actorGroup"), dict)
or actor.get("type") == ATLASSIAN_GROUP_ROLE_ACTOR_TYPE
)
class _PermissionUserEmailsCheck(_JiraCheck):
"""People who hold Browse Projects, directly or through a project role,
have emails. Permission sync maps them to Onyx users by email.
Passes for a public project (``anyone`` or ``applicationRole``), which syncs
without reading users, and for a project with group grants, which give
access through group sync. Fails only when every sampled user hides the
email and the sample covers every user the sync reads; a partial sample
with hidden emails is INDETERMINATE."""
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.DOC_PERMISSION_SYNC,
check_id="jira_permission_user_emails",
display_name="Users in permissions have emails",
remediation=(
"Make user emails visible to the token's account. "
f"Cloud: {_CLOUD_EMAIL_HINT} Data Center: {_DC_EMAIL_HINT}"
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
gateway: JiraSourceOperations = _gateway(context)
project_key, scheme = _read_scheme_for_dependent_check(context, config)
holders: list[dict[str, Any]] = _browse_holders(scheme)
holder_types: set[str] = {str(holder.get("type")) for holder in holders}
if _is_public(scheme) or HOLDER_TYPE_GROUP in holder_types:
return
# Direct user grants carry the expanded user.
emails: list[str | None] = [
holder["user"].get("emailAddress")
for holder in holders
if holder.get("type") == HOLDER_TYPE_USER
and isinstance(holder.get("user"), dict)
]
role_ids: list[str] = _browse_role_ids(scheme)
try:
roles: list[dict[str, Any]] = [
_read_role(context, config, project_key, role_id)
for role_id in role_ids[:_SAMPLE_ROLES]
]
except ConnectorValidationError as e:
raise UnexpectedValidationError(
"Onyx cannot read the project roles, so this check cannot run. "
"jira_project_roles_read reports why."
) from e
actors: list[dict[str, Any]] = [
actor
for role in roles
for actor in role.get("actors") or []
if isinstance(actor, dict)
]
if any(_is_group_actor(actor) for actor in actors):
return
role_users: list[dict[str, Any]] = [
user for actor in actors if (user := _role_actor_user(actor))
]
complete_sample: bool = (
len(role_ids) <= _SAMPLE_ROLES and len(role_users) <= _SAMPLE_USERS
)
for user in role_users[:_SAMPLE_USERS]:
lookup_id: str | None = _user_lookup_id(gateway, user)
if not lookup_id:
continue
try:
details: dict[str, Any] = gateway.get_user(user_id=lookup_id)
except JiraApiError as e:
_raise_for_api_error(
e,
denied=(
"The credential cannot read Jira users. "
f"{_perm_sync_hint(config, _GROUP_ACCESS_HINT)}"
),
)
account_type: Any = details.get("accountType")
if account_type is not None and account_type != _ATLASSIAN_ACCOUNT_TYPE:
# Apps and customers never map to Onyx users.
continue
emails.append(details.get("emailAddress"))
if emails and not any(emails):
if not complete_sample:
raise UnexpectedValidationError(
f"The sampled users who can browse the project `{project_key}` "
"have no visible email. Permission sync reads more users, so "
f"Onyx cannot verify the rest. {_email_hint(gateway)}"
)
raise InsufficientPermissionsError(
f"No user who can browse the project `{project_key}` has a visible "
"email, so their access cannot map to Onyx users. "
f"{_email_hint(gateway)}"
)
def _list_groups(
context: CapabilityCheckContext, config: JiraConnectorConfig
) -> JiraGroupPage:
try:
return _gateway(context).list_groups()
except JiraApiError as e:
_raise_for_api_error(
e,
denied=(
"The credential cannot list Jira groups. "
f"{_perm_sync_hint(config, _GROUP_ACCESS_HINT)}"
),
)
def _list_groups_for_dependent_check(
context: CapabilityCheckContext, config: JiraConnectorConfig
) -> list[str]:
try:
group_names: list[str] = _list_groups(context, config).group_names
except ConnectorValidationError as e:
raise UnexpectedValidationError(
"Onyx cannot list Jira groups, so this check cannot run. "
"jira_group_listing reports why."
) from e
if not group_names:
raise UnexpectedValidationError(
"Jira listed no groups, so Onyx cannot read group members. "
"jira_group_listing reports why."
)
return group_names
def _sample_group_members(
context: CapabilityCheckContext, config: JiraConnectorConfig
) -> JiraGroupMemberSample:
gateway: JiraSourceOperations = _gateway(context)
group_names: list[str] = _list_groups_for_dependent_check(context, config)
members: list[dict[str, Any]] = []
complete: bool = len(group_names) <= _SAMPLE_GROUPS
for group_name in group_names[:_SAMPLE_GROUPS]:
try:
page: dict[str, Any] = gateway.get_group_members_page(
group_name=group_name,
start_at=0,
max_results=_GROUP_MEMBER_PAGE_SIZE,
)
except JiraApiError as e:
if e.status_code == 404 and "does not exist" in (e.text or ""):
# The group was deleted after the listing; group sync skips it.
continue
_raise_for_api_error(
e,
denied=(
f"The credential cannot read the members of the group "
f"`{group_name}`. {_perm_sync_hint(config, _GROUP_ACCESS_HINT)}"
),
not_found=(
"Jira has no group-member API (GET /group/member, Jira 6.0+). "
"Upgrade Jira"
),
)
page_members: Any = page.get("values")
if isinstance(page_members, list):
members.extend(m for m in page_members if isinstance(m, dict))
if not page.get("isLast", True):
complete = False
return JiraGroupMemberSample(members=members, complete=complete)
class _GroupListingCheck(_JiraCheck):
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
check_id="jira_group_listing",
display_name="Groups can be listed",
remediation=(
f"{_GROUP_ACCESS_HINT} Scoped API token: the "
f"{_PERMISSION_SYNC_SCOPES} scopes."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
if not _list_groups(context, config).group_names:
# Group sync fails when the listing is empty.
raise InsufficientPermissionsError(
"Jira listed no groups, and group sync fails without groups. "
f"{_perm_sync_hint(config, _GROUP_ACCESS_HINT)}"
)
class _GroupListingCompleteCheck(_JiraCheck):
"""``groups/picker`` returns at most its result limit, which a site can set
lower than the number of groups."""
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
check_id="jira_group_listing_complete",
display_name="Group listing is complete",
required=False,
remediation=(
"Group sync skips the groups after the limit, so their members "
"lose access in Onyx. A Jira administrator can raise the "
"jira.ajax.autocomplete.limit setting (Data Center)."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
try:
page: JiraGroupPage = _list_groups(context, config)
except ConnectorValidationError as e:
raise UnexpectedValidationError(
"Onyx cannot list Jira groups, so this check cannot run. "
"jira_group_listing reports why."
) from e
if page.total is not None and page.total > len(page.group_names):
raise ConnectorValidationError(
f"Jira matched {page.total} groups but returned only "
f"{len(page.group_names)}, so group sync misses "
f"{page.total - len(page.group_names)} groups."
)
class _GroupMembershipCheck(_JiraCheck):
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
check_id="jira_group_membership",
display_name="Group members are readable",
remediation=(
f"{_GROUP_ACCESS_HINT} Scoped API token: the `read:jira-user` "
"scope, or the granular `read:group:jira`, `read:user:jira` and "
"`read:avatar:jira` scopes."
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
if not _sample_group_members(context, config).members:
# The reads succeeded: the first groups can be empty.
raise UnexpectedValidationError(
"The sampled groups have no members, so Onyx cannot verify "
"that group members are readable."
)
class _GroupMemberEmailsCheck(_JiraCheck):
def __init__(self) -> None:
super().__init__(
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
check_id="jira_group_member_emails",
display_name="Group members have emails",
remediation=(
"Make user emails visible to the token's account. "
f"Cloud: {_CLOUD_EMAIL_HINT} Data Center: {_DC_EMAIL_HINT}"
),
)
def run(self, context: CapabilityCheckContext) -> None:
config: JiraConnectorConfig = self.config(context)
try:
sample: JiraGroupMemberSample = _sample_group_members(context, config)
except ConnectorValidationError as e:
raise UnexpectedValidationError(
"Onyx cannot read group members, so this check cannot run. "
"jira_group_membership reports why."
) from e
# Group sync skips app and customer accounts.
people: list[dict[str, Any]] = [
member
for member in sample.members
if member.get("accountType") in (None, _ATLASSIAN_ACCOUNT_TYPE)
]
if people and not any(member.get("emailAddress") for member in people):
if not sample.complete:
raise UnexpectedValidationError(
"The sampled group members have no visible email. Group sync "
"reads more groups and members, so Onyx cannot verify the "
f"rest. {_email_hint(_gateway(context))}"
)
raise InsufficientPermissionsError(
"No member of the sampled group has a visible email, so group "
f"members cannot map to Onyx users. {_email_hint(_gateway(context))}"
)
def build_jira_doc_permission_sync_checks() -> list[CapabilityCheck]:
return [
_PermissionSchemeReadCheck(),
_ProjectAccessMappableCheck(),
_ProjectRolesReadCheck(),
_PermissionUserEmailsCheck(),
]
def build_jira_group_sync_checks() -> list[CapabilityCheck]:
return [
_GroupListingCheck(),
_GroupListingCompleteCheck(),
_GroupMembershipCheck(),
_GroupMemberEmailsCheck(),
]
+10
View File
@@ -1,3 +1,5 @@
from typing import Any
from pydantic import BaseModel
@@ -15,3 +17,11 @@ class JiraGroupPage(BaseModel):
group_names: list[str]
total: int | None
class JiraGroupMemberSample(BaseModel):
"""The first page of members of the first listed groups. ``complete`` is
True when those pages are every member of every listed group."""
members: list[dict[str, Any]]
complete: bool
@@ -41,7 +41,6 @@ _MAX_RESULTS_FETCH_IDS = 5000
# https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
_JIRA_BULK_FETCH_LIMIT = 100
_GROUP_PICKER_MAX_RESULTS = 9999
_UNTESTED = "Capability checks land in a follow-up PR."
class JiraApiError(Exception):
@@ -316,7 +315,6 @@ class JiraSourceOperations(SourceOperations):
@source_operation(
capabilities={CredentialCapability.DOC_PERMISSION_SYNC},
consumes=OperationConsumes.CREDENTIAL,
untested=_UNTESTED,
)
def get_project_permission_scheme(self, *, project_key: str) -> dict[str, Any]:
"""Returns the permission scheme of a project with its grants and
@@ -329,7 +327,6 @@ class JiraSourceOperations(SourceOperations):
@source_operation(
capabilities={CredentialCapability.DOC_PERMISSION_SYNC},
consumes=OperationConsumes.CREDENTIAL,
untested=_UNTESTED,
)
def get_project_role(self, *, project_key: str, role_id: str) -> dict[str, Any]:
"""Returns a project role with its actors (users and groups)."""
@@ -339,7 +336,6 @@ class JiraSourceOperations(SourceOperations):
@source_operation(
capabilities={CredentialCapability.DOC_PERMISSION_SYNC},
consumes=OperationConsumes.CREDENTIAL,
untested=_UNTESTED,
)
def get_user(self, *, user_id: str) -> dict[str, Any]:
"""Returns a user: ``user_id`` is the accountId on Cloud and the
@@ -350,7 +346,6 @@ class JiraSourceOperations(SourceOperations):
@source_operation(
capabilities={CredentialCapability.EXTERNAL_GROUP_SYNC},
consumes=OperationConsumes.CREDENTIAL,
untested=_UNTESTED,
)
def list_groups(self) -> JiraGroupPage:
"""Returns the group names from ``groups/picker``, sorted."""
@@ -368,7 +363,6 @@ class JiraSourceOperations(SourceOperations):
@source_operation(
capabilities={CredentialCapability.EXTERNAL_GROUP_SYNC},
consumes=OperationConsumes.CREDENTIAL,
untested=_UNTESTED,
)
def get_group_members_page(
self, *, group_name: str, start_at: int, max_results: int
+21
View File
@@ -12,6 +12,27 @@ PROJECT_URL_PAT = "projects"
JIRA_SERVER_API_VERSION = os.environ.get("JIRA_SERVER_API_VERSION") or "2"
JIRA_CLOUD_API_VERSION = os.environ.get("JIRA_CLOUD_API_VERSION") or "3"
# Permission sync reads who holds this permission in each project.
BROWSE_PROJECTS_PERMISSION = "BROWSE_PROJECTS"
HOLDER_TYPE_ANYONE = "anyone"
HOLDER_TYPE_APPLICATION_ROLE = "applicationRole"
HOLDER_TYPE_USER = "user"
HOLDER_TYPE_PROJECT_ROLE = "projectRole"
HOLDER_TYPE_GROUP = "group"
SUPPORTED_STATIC_HOLDER_TYPES = {
HOLDER_TYPE_ANYONE,
HOLDER_TYPE_APPLICATION_ROLE,
HOLDER_TYPE_USER,
HOLDER_TYPE_PROJECT_ROLE,
HOLDER_TYPE_GROUP,
}
# Jira DC/Server returns project-role actors flat with this `type` discriminator;
# Jira Cloud v3 instead wraps them in nested `actorGroup` / `actorUser` objects.
ATLASSIAN_GROUP_ROLE_ACTOR_TYPE = "atlassian-group-role-actor"
ATLASSIAN_USER_ROLE_ACTOR_TYPE = "atlassian-user-role-actor"
def best_effort_basic_expert_info(obj: Any) -> BasicExpertInfo | None:
display_name = None
@@ -7,6 +7,7 @@ import pytest
from onyx.configs.constants import DocumentSource
from onyx.connectors.capability_checks.models import (
CapabilityCheck,
CapabilityCheckContext,
CapabilityCheckResult,
CapabilityCheckStatus,
@@ -16,8 +17,13 @@ from onyx.connectors.capability_checks.models import (
)
from onyx.connectors.capability_checks.runner import run_capability_checks
from onyx.connectors.credentials_provider import OnyxStaticCredentialsProvider
from onyx.connectors.jira.capability_checks import build_jira_indexing_checks
from onyx.connectors.jira.capability_checks import (
build_jira_doc_permission_sync_checks,
build_jira_group_sync_checks,
build_jira_indexing_checks,
)
from onyx.connectors.jira.source_operations import JiraSourceOperations
from onyx.db.enums import AccessType
from tests.utils.secret_names import TestSecret
pytestmark = pytest.mark.secrets(
@@ -54,12 +60,15 @@ def _credential(
def _results(
credential_json: dict[str, Any], config: dict[str, Any]
credential_json: dict[str, Any],
config: dict[str, Any],
perm_sync: bool = False,
) -> dict[str, CapabilityCheckResult]:
context: CapabilityCheckContext = CapabilityCheckContext(
source=DocumentSource.JIRA,
credential_json=credential_json,
connector_specific_config=config,
access_type=AccessType.SYNC if perm_sync else None,
source_operations=JiraSourceOperations(
credentials_provider=OnyxStaticCredentialsProvider(
None, DocumentSource.JIRA.value, credential_json
@@ -67,9 +76,12 @@ def _results(
connector_specific_config=config,
),
)
results: list[CapabilityCheckResult] = run_capability_checks(
build_jira_indexing_checks(), context
checks: list[CapabilityCheck] = (
build_jira_doc_permission_sync_checks() + build_jira_group_sync_checks()
if perm_sync
else build_jira_indexing_checks()
)
results: list[CapabilityCheckResult] = run_capability_checks(checks, context)
return {result.check_id: result for result in results}
@@ -192,3 +204,53 @@ def test_scoped_sign_in_fails_for_a_bad_token(
result: CapabilityCheckResult = by_id["jira_scoped_token_auth"]
assert result.status == CapabilityCheckStatus.FAILED, result
def test_perm_sync_checks_pass_with_the_classic_token(
test_secrets: dict[TestSecret, str],
) -> None:
"""The test account is a Jira admin, so every permission-sync and
group-sync check passes."""
by_id = _results(
_credential(test_secrets, False),
_form(False, project_key=_PROJECT),
perm_sync=True,
)
failures = {
check_id: result.message
for check_id, result in by_id.items()
if result.status != CapabilityCheckStatus.PASSED
}
assert not failures, failures
verdicts = compute_capability_verdicts(
{
CredentialCapability.DOC_PERMISSION_SYNC,
CredentialCapability.EXTERNAL_GROUP_SYNC,
},
list(by_id.values()),
)
assert (
verdicts[CredentialCapability.DOC_PERMISSION_SYNC] == CapabilityVerdict.PASSED
)
assert (
verdicts[CredentialCapability.EXTERNAL_GROUP_SYNC] == CapabilityVerdict.PASSED
)
def test_ci_scoped_token_cannot_read_group_members(
test_secrets: dict[TestSecret, str],
) -> None:
"""The CI scoped token reads permission schemes and project roles but lacks
a scope that ``group/member`` needs (HTTP 401: scope does not match), so
group sync cannot work with it."""
by_id = _results(
_credential(test_secrets, True),
_form(True, project_key=_PROJECT),
perm_sync=True,
)
assert by_id["jira_permission_scheme_read"].status == CapabilityCheckStatus.PASSED
membership = by_id["jira_group_membership"]
assert membership.status == CapabilityCheckStatus.FAILED, membership
assert "read:jira-user" in membership.message
@@ -0,0 +1,455 @@
from typing import Any
from unittest.mock import MagicMock, create_autospec
import pytest
from onyx.configs.constants import DocumentSource
from onyx.connectors.capability_checks.models import (
CapabilityCheckContext,
CapabilityCheckResult,
CapabilityCheckStatus,
)
from onyx.connectors.capability_checks.runner import run_capability_checks
from onyx.connectors.jira.capability_checks import (
build_jira_doc_permission_sync_checks,
build_jira_group_sync_checks,
)
from onyx.connectors.jira.models import JiraGroupPage, JiraIssueIdPage
from onyx.connectors.jira.source_operations import JiraApiError, JiraSourceOperations
from onyx.db.enums import AccessType
_CONFIG = {
"jira_base_url": "https://example.atlassian.net",
"scoped_token": False,
"project_key": "AS",
"jql_query": "",
}
_SCOPE_MISMATCH = '{"code":401,"message":"Unauthorized; scope does not match"}'
def _browse(holder: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "permission": "BROWSE_PROJECTS", "holder": holder}
def _gateway(*, is_cloud: bool = True) -> MagicMock:
gateway = create_autospec(JiraSourceOperations, instance=True)
gateway._is_cloud.return_value = is_cloud
gateway.list_projects.return_value = [{"key": "AS"}]
gateway.get_project_permission_scheme.return_value = {
"permissions": [_browse({"type": "projectRole", "value": "10003"})]
}
gateway.get_project_role.return_value = {
"actors": [{"actorUser": {"accountId": "acc-1"}}]
}
gateway.get_user.return_value = {
"accountType": "atlassian",
"emailAddress": "dev@example.com",
}
gateway.list_groups.return_value = JiraGroupPage(group_names=["devs"], total=1)
gateway.get_group_members_page.return_value = {
"values": [{"accountType": "atlassian", "emailAddress": "dev@example.com"}],
"isLast": True,
}
return gateway
def _run(
check_id: str,
gateway: MagicMock,
config: dict[str, Any] | None = None,
access_type: AccessType = AccessType.SYNC,
) -> CapabilityCheckResult:
checks = [
check
for check in build_jira_doc_permission_sync_checks()
+ build_jira_group_sync_checks()
if check.check_id == check_id
]
context = CapabilityCheckContext(
source=DocumentSource.JIRA,
credential_json={"jira_user_email": "bot@example.com", "jira_api_token": "t"},
connector_specific_config=config or _CONFIG,
access_type=access_type,
source_operations=gateway,
)
(result,) = run_capability_checks(checks, context)
return result
def _api_error(status: int, text: str | None = None) -> JiraApiError:
return JiraApiError(f"HTTP {status}", status_code=status, text=text)
def test_all_checks_pass_on_a_readable_site() -> None:
gateway = _gateway()
for check in (
build_jira_doc_permission_sync_checks() + build_jira_group_sync_checks()
):
result = _run(check.check_id, gateway)
assert result.status == CapabilityCheckStatus.PASSED, result
def test_perm_sync_checks_do_not_apply_to_public_connectors() -> None:
result = _run(
"jira_permission_scheme_read", _gateway(), access_type=AccessType.PUBLIC
)
assert not result.applicable
# --- jira_permission_scheme_read ---
def test_permission_scheme_read_fails_without_admin_access() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.side_effect = _api_error(403)
result = _run("jira_permission_scheme_read", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert "Administer Jira" in result.message
def test_permission_scheme_read_names_scopes_for_a_scoped_token() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.side_effect = _api_error(401, _SCOPE_MISMATCH)
result = _run(
"jira_permission_scheme_read", gateway, {**_CONFIG, "scoped_token": True}
)
assert result.status == CapabilityCheckStatus.FAILED
assert "manage:jira-configuration" in result.message
def test_permission_scheme_read_probes_the_first_project_without_a_project() -> None:
gateway = _gateway()
_run("jira_permission_scheme_read", gateway, {**_CONFIG, "project_key": ""})
gateway.get_project_permission_scheme.assert_called_once_with(project_key="AS")
def test_permission_scheme_read_probes_the_project_of_a_jql_match() -> None:
gateway = _gateway()
gateway.list_projects.return_value = [{"key": "OTHER"}]
gateway.search_issue_ids.return_value = JiraIssueIdPage(
issue_ids=["1"], next_page_token=None
)
gateway.bulk_fetch_issues.return_value = [
{"key": "AS-1", "fields": {"project": {"key": "AS"}}}
]
_run(
"jira_permission_scheme_read",
gateway,
{**_CONFIG, "project_key": "", "jql_query": 'project = "AS"'},
)
gateway.get_project_permission_scheme.assert_called_once_with(project_key="AS")
def test_permission_scheme_read_is_indeterminate_when_the_jql_matches_nothing() -> None:
gateway = _gateway()
gateway.search_issue_ids.return_value = JiraIssueIdPage(
issue_ids=[], next_page_token=None
)
result = _run(
"jira_permission_scheme_read",
gateway,
{**_CONFIG, "project_key": "", "jql_query": 'project = "EMPTY"'},
)
assert result.status == CapabilityCheckStatus.INDETERMINATE
gateway.get_project_permission_scheme.assert_not_called()
def test_permission_scheme_read_fails_for_a_scheme_without_grants() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {"id": 1}
result = _run("jira_permission_scheme_read", gateway)
assert result.status == CapabilityCheckStatus.FAILED
# --- jira_project_access_mappable ---
def test_project_access_mappable_warns_on_dynamic_grants() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [_browse({"type": "reporter"}), _browse({"type": "assignee"})]
}
result = _run("jira_project_access_mappable", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert not result.required
assert "assignee, reporter" in result.message
# --- jira_project_roles_read ---
def test_project_roles_read_fails_when_roles_are_hidden() -> None:
gateway = _gateway()
gateway.get_project_role.side_effect = _api_error(403)
result = _run("jira_project_roles_read", gateway)
assert result.status == CapabilityCheckStatus.FAILED
def test_project_roles_read_passes_without_role_grants() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [_browse({"type": "group", "parameter": "devs"})]
}
result = _run("jira_project_roles_read", gateway)
assert result.status == CapabilityCheckStatus.PASSED
gateway.get_project_role.assert_not_called()
def test_dependent_checks_defer_to_the_scheme_check() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.side_effect = _api_error(403)
for check_id in (
"jira_project_access_mappable",
"jira_project_roles_read",
"jira_permission_user_emails",
):
assert _run(check_id, gateway).status == CapabilityCheckStatus.INDETERMINATE
# --- jira_permission_user_emails ---
def test_permission_user_emails_fails_when_emails_are_hidden() -> None:
gateway = _gateway()
gateway.get_user.return_value = {"accountType": "atlassian", "emailAddress": ""}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert "Profile and visibility" in result.message
@pytest.mark.parametrize(
"extra_holder",
[
{"type": "applicationRole"},
{"type": "anyone"},
{"type": "group", "parameter": "devs"},
],
ids=["application-role", "anyone", "group"],
)
def test_permission_user_emails_passes_for_public_or_group_grants(
extra_holder: dict[str, Any],
) -> None:
"""Public grants sync without reading users; group grants give access
through group sync. Hidden user emails must not block either."""
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [
_browse({"type": "projectRole", "value": "10003"}),
_browse(extra_holder),
]
}
gateway.get_user.return_value = {"accountType": "atlassian", "emailAddress": ""}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.PASSED
gateway.get_user.assert_not_called()
def test_permission_user_emails_passes_when_a_role_holds_a_group() -> None:
gateway = _gateway()
gateway.get_project_role.return_value = {
"actors": [
{"actorGroup": {"name": "devs"}},
{"actorUser": {"accountId": "acc-1"}},
]
}
gateway.get_user.return_value = {"accountType": "atlassian", "emailAddress": ""}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.PASSED
def test_permission_user_emails_skips_app_accounts() -> None:
gateway = _gateway()
gateway.get_user.return_value = {"accountType": "app"}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.PASSED
def test_permission_user_emails_looks_up_data_center_users_by_name() -> None:
gateway = _gateway(is_cloud=False)
gateway.get_project_role.return_value = {
"actors": [{"type": "atlassian-user-role-actor", "name": "jdoe"}]
}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.PASSED
gateway.get_user.assert_called_once_with(user_id="jdoe")
def test_permission_user_emails_reads_direct_user_grants() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [
_browse({"type": "user", "value": "acc-2", "user": {"emailAddress": ""}})
]
}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.FAILED
gateway.get_user.assert_not_called()
def test_permission_user_emails_is_indeterminate_for_a_partial_sample() -> None:
"""Sync reads every role user; hidden emails in a sample prove nothing."""
gateway = _gateway()
gateway.get_project_role.return_value = {
"actors": [{"actorUser": {"accountId": f"acc-{i}"}} for i in range(6)]
}
gateway.get_user.return_value = {"accountType": "atlassian", "emailAddress": ""}
result = _run("jira_permission_user_emails", gateway)
assert result.status == CapabilityCheckStatus.INDETERMINATE
def test_permission_user_emails_reads_every_role_up_to_the_sample() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [
_browse({"type": "projectRole", "value": str(role_id)})
for role_id in range(4)
]
}
gateway.get_user.return_value = {"accountType": "atlassian", "emailAddress": ""}
result = _run("jira_permission_user_emails", gateway)
assert gateway.get_project_role.call_count == 3
assert result.status == CapabilityCheckStatus.INDETERMINATE
def test_project_roles_read_skips_public_projects() -> None:
gateway = _gateway()
gateway.get_project_permission_scheme.return_value = {
"permissions": [
_browse({"type": "applicationRole"}),
_browse({"type": "projectRole", "value": "10003"}),
]
}
gateway.get_project_role.side_effect = _api_error(403)
result = _run("jira_project_roles_read", gateway)
assert result.status == CapabilityCheckStatus.PASSED
gateway.get_project_role.assert_not_called()
# --- group sync ---
def test_group_listing_fails_when_empty() -> None:
gateway = _gateway()
gateway.list_groups.return_value = JiraGroupPage(group_names=[], total=0)
result = _run("jira_group_listing", gateway)
assert result.status == CapabilityCheckStatus.FAILED
def test_group_listing_complete_warns_on_a_truncated_listing() -> None:
gateway = _gateway()
gateway.list_groups.return_value = JiraGroupPage(group_names=["a", "b"], total=5)
result = _run("jira_group_listing_complete", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert not result.required
assert "misses 3 groups" in result.message
def test_group_membership_fails_for_a_scoped_token_without_the_scope() -> None:
gateway = _gateway()
gateway.get_group_members_page.side_effect = _api_error(401, _SCOPE_MISMATCH)
result = _run("jira_group_membership", gateway, {**_CONFIG, "scoped_token": True})
assert result.status == CapabilityCheckStatus.FAILED
assert result.error_type == "InsufficientPermissionsError"
assert "read:jira-user" in result.message
def test_group_membership_skips_a_deleted_group() -> None:
gateway = _gateway()
gateway.list_groups.return_value = JiraGroupPage(
group_names=["gone", "devs"], total=2
)
gateway.get_group_members_page.side_effect = [
_api_error(
404, '{"errorMessages":["The group named \'gone\' does not exist"]}'
),
{"values": [{"emailAddress": "dev@example.com"}]},
]
result = _run("jira_group_membership", gateway)
assert result.status == CapabilityCheckStatus.PASSED
def test_group_membership_fails_without_the_group_member_api() -> None:
gateway = _gateway()
gateway.get_group_members_page.side_effect = _api_error(404, "Not Found")
result = _run("jira_group_membership", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert "Jira 6.0+" in result.message
def test_group_member_emails_fails_when_emails_are_hidden() -> None:
gateway = _gateway(is_cloud=False)
gateway.get_group_members_page.return_value = {"values": [{"name": "jdoe"}]}
result = _run("jira_group_member_emails", gateway)
assert result.status == CapabilityCheckStatus.FAILED
assert "User email visibility" in result.message
@pytest.mark.parametrize(
"group_names,is_last",
[(["devs"], False), (["a", "b", "c", "d"], True)],
ids=["more-pages", "more-groups"],
)
def test_group_member_emails_is_indeterminate_for_a_partial_sample(
group_names: list[str], is_last: bool
) -> None:
gateway = _gateway(is_cloud=False)
gateway.list_groups.return_value = JiraGroupPage(
group_names=group_names, total=len(group_names)
)
gateway.get_group_members_page.return_value = {
"values": [{"name": "jdoe"}],
"isLast": is_last,
}
result = _run("jira_group_member_emails", gateway)
assert result.status == CapabilityCheckStatus.INDETERMINATE
@@ -18,6 +18,7 @@ from onyx.connectors.confluence.source_operations import (
ConfluenceRestSpacePermissionsNotAvailableError,
ConfluenceSpacePermissionsVariant,
)
from onyx.connectors.jira.models import JiraGroupPage
from onyx.connectors.source_operations import (
SourceOperations,
registered_source_operations,
@@ -45,14 +46,35 @@ def _configure_confluence_spy(spy: MagicMock) -> None:
spy.get_space_permissions.side_effect = get_space_permissions
def _configure_jira_site(spy: MagicMock) -> None:
"""A project that grants Browse Projects to a role with one user, and a
group with one member, so the permission checks reach every read."""
spy.list_projects.return_value = [{"key": "AS"}]
spy.get_project_permission_scheme.return_value = {
"permissions": [
{
"permission": "BROWSE_PROJECTS",
"holder": {"type": "projectRole", "value": "10003"},
}
]
}
spy.get_project_role.return_value = {
"actors": [{"actorUser": {"accountId": "a1", "name": "a1"}}]
}
spy.list_groups.return_value = JiraGroupPage(group_names=["devs"], total=1)
spy.get_group_members_page.return_value = {"values": [{"name": "a1"}]}
def _configure_jira_cloud_spy(spy: MagicMock) -> None:
"""A Cloud credential: the checks search with enhanced search and bulk
fetch."""
_configure_jira_site(spy)
spy._is_cloud.return_value = True
def _configure_jira_server_spy(spy: MagicMock) -> None:
"""A Data Center credential: the checks search with the v2 search."""
_configure_jira_site(spy)
spy._is_cloud.return_value = False