mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(connectors): named permission-sync and group-sync checks for Jira (#15772)
This commit is contained in:
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(),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user