From 8af1a58f410d6dbac0728e5c3c811fac8c0efff5 Mon Sep 17 00:00:00 2001 From: Daniel Brauer Date: Mon, 9 Dec 2024 17:13:54 -0500 Subject: [PATCH] user org role expressed as relationship to org instead of user node property Signed-off-by: Daniel Brauer --- cartography/intel/github/users.py | 5 ++- cartography/models/github/orgs.py | 2 +- cartography/models/github/users.py | 21 +++++++++---- docs/root/modules/github/schema.md | 31 ++++++++++++++++--- .../cartography/intel/github/test_users.py | 17 +++++----- 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/cartography/intel/github/users.py b/cartography/intel/github/users.py index bf26bebe4a..2b116f63c4 100644 --- a/cartography/intel/github/users.py +++ b/cartography/intel/github/users.py @@ -142,10 +142,13 @@ def transform_users(user_data: List[Dict], owners_data: List[Dict], org_data: Di users_dict = {} for user in user_data: + # all members get the 'MEMBER_OF' relationship processed_user = deepcopy(user['node']) - processed_user['role'] = user['role'] processed_user['hasTwoFactorEnabled'] = user['hasTwoFactorEnabled'] processed_user['MEMBER_OF'] = org_data['url'] + # admins get a second relationship expressing them as such + if user['role'] == 'ADMIN': + processed_user['ADMIN_OF'] = org_data['url'] users_dict[processed_user['url']] = processed_user owners_dict = {} diff --git a/cartography/models/github/orgs.py b/cartography/models/github/orgs.py index 41ae4f5631..07f107276d 100644 --- a/cartography/models/github/orgs.py +++ b/cartography/models/github/orgs.py @@ -1,7 +1,7 @@ """ This schema does not handle the org's relationships. Those are handled by other schemas, for example: * GitHubTeamSchema defines (GitHubOrganization)-[RESOURCE]->(GitHubTeam) -* GitHubUserSchema defines (GitHubUser)-[MEMBER_OF|UNAFFILIATED]->(GitHubOrganization) +* GitHubUserSchema defines (GitHubUser)-[MEMBER_OF|ADMIN_OF|UNAFFILIATED]->(GitHubOrganization) (There may be others, these are just two examples.) """ from dataclasses import dataclass diff --git a/cartography/models/github/users.py b/cartography/models/github/users.py index 387652dd90..b4962b5b27 100644 --- a/cartography/models/github/users.py +++ b/cartography/models/github/users.py @@ -20,13 +20,12 @@ RE: GitHubOrganizationUserSchema vs GitHubUnaffiliatedUserSchema As noted above, there are implicitly two types of users, those that are part of, or affiliated, to a target -GitHubOrganization, and those thare are not part, or unaffiliated. Both are represented as GitHubUser nodes, -but there are two schemas below to allow for some differences between them, e.g., unaffiliated lack these properties: - * the 'role' property, because unaffiliated have no 'role' in the target org +GitHubOrganization, and those that are not part, or unaffiliated. Both are represented as GitHubUser nodes, +but there are two schemas below to allow for a difference between them: unaffiliated nodes lack this property: * the 'has_2fa_enabled' property, because the GitHub api does not return it, for these users The main importance of having two schemas is to allow the two sets of users to be loaded separately. If we are loading an unaffiliated user, but the user already exists in the graph (perhaps they are members of another GitHub orgs for -example), then loading the unaffiliated user will not blank out the 'role' and 'has_2fa_enabled' properties. +example), then loading the unaffiliated user will not blank out the 'has_2fa_enabled' property. """ from dataclasses import dataclass @@ -58,8 +57,6 @@ class BaseGitHubUserNodeProperties(CartographyNodeProperties): class GitHubOrganizationUserNodeProperties(BaseGitHubUserNodeProperties): # specified for affiliated users only. The GitHub api does not return this property for unaffiliated users. has_2fa_enabled: PropertyRef = PropertyRef('hasTwoFactorEnabled') - # specified for affiliated uers only. Unaffiliated users do not have a 'role' in the target organization. - role: PropertyRef = PropertyRef('role') @dataclass(frozen=True) @@ -84,6 +81,17 @@ class GitHubUserMemberOfOrganizationRel(CartographyRelSchema): properties: GitHubUserToOrganizationRelProperties = GitHubUserToOrganizationRelProperties() +@dataclass(frozen=True) +class GitHubUserAdminOfOrganizationRel(CartographyRelSchema): + target_node_label: str = 'GitHubOrganization' + target_node_matcher: TargetNodeMatcher = make_target_node_matcher( + {'id': PropertyRef('ADMIN_OF')}, + ) + direction: LinkDirection = LinkDirection.OUTWARD + rel_label: str = "ADMIN_OF" + properties: GitHubUserToOrganizationRelProperties = GitHubUserToOrganizationRelProperties() + + @dataclass(frozen=True) class GitHubUserUnaffiliatedOrganizationRel(CartographyRelSchema): target_node_label: str = 'GitHubOrganization' @@ -102,6 +110,7 @@ class GitHubOrganizationUserSchema(CartographyNodeSchema): other_relationships: OtherRelationships = OtherRelationships( [ GitHubUserMemberOfOrganizationRel(), + GitHubUserAdminOfOrganizationRel(), ], ) sub_resource_relationship = None diff --git a/docs/root/modules/github/schema.md b/docs/root/modules/github/schema.md index e0846b150d..61bd519e81 100644 --- a/docs/root/modules/github/schema.md +++ b/docs/root/modules/github/schema.md @@ -94,10 +94,21 @@ Representation of a single GitHubOrganization [organization object](https://deve (GitHubOrganization)-[RESOURCE]->(GitHubTeam) ``` -- GitHubUsers are members of an organization. In some cases there may be a user who is "unaffiliated" with an org, for example if the user is an enterprise owner, but not member of, the org. [Enterprise owners](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners) have complete control over the enterprise (i.e. they can manage all enterprise settings, members, and policies) yet may not show up on member lists of the GitHub org. +- GitHubUsers relate to GitHubOrganizations in a few ways: + - Most typically, they are members of an organization. + - They may also be org admins (aka org owners), with broad permissions over repo and team settings. In these cases, they will be graphed with two relationships between GitHubUser and GitHubOrganization, both `MEMBER_OF` and `ADMIN_OF`. + - In some cases there may be a user who is "unaffiliated" with an org, for example if the user is an enterprise owner, but not member of, the org. [Enterprise owners](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners) have complete control over the enterprise (i.e. they can manage all enterprise settings, members, and policies) yet may not show up on member lists of the GitHub org. ``` - (GitHubUser)-[MEMBER_OF|UNAFFILIATED]->(GitHubOrganization) + # a typical member + (GitHubUser)-[MEMBER_OF]->(GitHubOrganization) + + # an admin member has two relationships to the org + (GitHubUser)-[MEMBER_OF]->(GitHubOrganization) + (GitHubUser)-[ADMIN_OF]->(GitHubOrganization) + + # an unaffiliated user (e.g. an enterprise owner) + (GitHubUser)-[UNAFFILIATED]->(GitHubOrganization) ``` @@ -148,7 +159,6 @@ Representation of a single GitHubUser [user object](https://developer.github.com | username | Name of the user | | fullname | The full name | | has_2fa_enabled | Whether the user has 2-factor authentication enabled | -| role | Either 'ADMIN' (denoting that the user is an owner of a Github organization) or 'MEMBER' | | is_site_admin | Whether the user is a site admin | | is_enterprise_owner | Whether the user is an [enterprise owner](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners) | | permission | Only present if the user is an [outside collaborator](https://docs.github.com/en/graphql/reference/objects#repositorycollaboratorconnection) of this repo. `permission` is either ADMIN, MAINTAIN, READ, TRIAGE, or WRITE ([ref](https://docs.github.com/en/graphql/reference/enums#repositorypermission)). | @@ -178,10 +188,21 @@ WRITE, MAINTAIN, TRIAGE, and READ ([Reference](https://docs.github.com/en/graphq (GitHubUser)-[:DIRECT_COLLAB_{ACTION}]->(GitHubRepository) ``` -- GitHubUsers are members of an organization. In some cases there may be a user who is "unaffiliated" with an org, for example if the user is an enterprise owner, but not member of, the org. [Enterprise owners](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners) have complete control over the enterprise (i.e. they can manage all enterprise settings, members, and policies) yet may not show up on member lists of the GitHub org. +- GitHubUsers relate to GitHubOrganizations in a few ways: + - Most typically, they are members of an organization. + - They may also be org admins (aka org owners), with broad permissions over repo and team settings. In these cases, they will be graphed with two relationships between GitHubUser and GitHubOrganization, both `MEMBER_OF` and `ADMIN_OF`. + - In some cases there may be a user who is "unaffiliated" with an org, for example if the user is an enterprise owner, but not member of, the org. [Enterprise owners](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners) have complete control over the enterprise (i.e. they can manage all enterprise settings, members, and policies) yet may not show up on member lists of the GitHub org. ``` - (GitHubUser)-[MEMBER_OF|UNAFFILIATED]->(GitHubOrganization) + # a typical member + (GitHubUser)-[MEMBER_OF]->(GitHubOrganization) + + # an admin member has two relationships to the org + (GitHubUser)-[MEMBER_OF]->(GitHubOrganization) + (GitHubUser)-[ADMIN_OF]->(GitHubOrganization) + + # an unaffiliated user (e.g. an enterprise owner) + (GitHubUser)-[UNAFFILIATED]->(GitHubOrganization) ``` - GitHubUsers may be ['immediate'](https://docs.github.com/en/graphql/reference/enums#teammembershiptype) members of a team (as opposed to being members via membership in a child team), with their membership [role](https://docs.github.com/en/graphql/reference/enums#teammemberrole) being MEMBER or MAINTAINER. diff --git a/tests/integration/cartography/intel/github/test_users.py b/tests/integration/cartography/intel/github/test_users.py index 33fe160145..653f98190f 100644 --- a/tests/integration/cartography/intel/github/test_users.py +++ b/tests/integration/cartography/intel/github/test_users.py @@ -48,22 +48,21 @@ def test_sync(mock_owners, mock_users, neo4j_session): # Assert - # Ensure users got loaded + # Ensure the expected users are there nodes = neo4j_session.run( """ - MATCH (g:GitHubUser) RETURN g.id, g.role; + MATCH (g:GitHubUser) RETURN g.id; """, ) expected_nodes = { - ("https://example.com/hjsimpson", 'MEMBER'), - ("https://example.com/lmsimpson", 'MEMBER'), - ("https://example.com/mbsimpson", 'ADMIN'), - ("https://example.com/kbroflovski", None), + ("https://example.com/hjsimpson",), + ("https://example.com/lmsimpson",), + ("https://example.com/mbsimpson",), + ("https://example.com/kbroflovski",), } actual_nodes = { ( n['g.id'], - n['g.role'], ) for n in nodes } assert actual_nodes == expected_nodes @@ -95,6 +94,10 @@ def test_sync(mock_owners, mock_users, neo4j_session): 'https://example.com/mbsimpson', 'MEMBER_OF', 'https://example.com/my_org', + ), ( + 'https://example.com/mbsimpson', + 'ADMIN_OF', + 'https://example.com/my_org', ), ( 'https://example.com/kbroflovski', 'UNAFFILIATED',