Skip to content

Commit

Permalink
User groups (#146)
Browse files Browse the repository at this point in the history
Adds group members and group membership to userinfo
  • Loading branch information
obriencj committed Oct 3, 2023
1 parent 2fcaa7c commit 21395ee
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 22 deletions.
6 changes: 4 additions & 2 deletions docs/commands/userinfo.rst
Expand Up @@ -32,8 +32,9 @@ additional output:
* Last task summary
* Last build summary

Since version 2.2.0, this command will also show the list of members
if the specified user ID is actually a group
Since version 2.2.0, this command will also list the groups that a
user is a member of, or the list of members if the specified user ID
is actually a group.


References
Expand All @@ -43,3 +44,4 @@ References
* :py:func:`kojismokydingo.cli.users.cli_userinfo`
* :py:func:`kojismokydingo.users.collect_userinfo`
* :py:func:`kojismokydingo.users.get_group_members`
* :py:func:`kojismokydingo.users.get_user_groups`
4 changes: 4 additions & 0 deletions docs/release_notes/v2.2.0.rst
Expand Up @@ -15,6 +15,9 @@ Commands
being used, which always failed because it required the admin
permission and the command was anonymous. New implementation uses
``queryHistory``
* Support for future koji 1.35 which has anonymous ``getGroupMembers``
and ``getUserGroups`` calls
* Added group membership information to the ``userinfo`` command.


API
Expand All @@ -35,6 +38,7 @@ Other

* moved documentation build into a github action workflow, removing
gh-pages submodule and related Makefile targets
* migrated away from the ``build_sphinx`` setuptools command


Issues
Expand Down
32 changes: 32 additions & 0 deletions kojismokydingo/__init__.py
Expand Up @@ -92,6 +92,7 @@
"bulk_load_users",
"hub_version",
"iter_bulk_load",
# "paged_query_history",
"version_check",
"version_require",
)
Expand Down Expand Up @@ -1303,6 +1304,37 @@ def as_userinfo(
return info


# koji's queryHistory call doesn't support queryOpts for some reason
#
# def paged_query_history(
# session: ClientSession,
# table: str,
# pagesize: int = 100,
# **kwargs: Any):
#
# qopts = {
# "order": "create_event",
# "offset": 0,
# "limit": pagesize,
# }
# kwargs["tables"] = [table]
# kwargs["queryOpts"] = qopts
#
# while True:
# hist = session.queryHistory(**kwargs)[table]
# histlen = len(hist)
#
# if histlen == 0:
# break
#
# yield hist
#
# if histlen < pagesize:
# break
#
# qopts["offset"] += histlen


def _int(val):
if isinstance(val, str) and val.isdigit():
val = int(val)
Expand Down
8 changes: 7 additions & 1 deletion kojismokydingo/cli/users.py
Expand Up @@ -133,7 +133,7 @@ def cli_userinfo(
:since: 1.0
"""

userinfo = collect_userinfo(session, user, stats)
userinfo = collect_userinfo(session, user, stats, members=True)

if json:
pretty_json(userinfo)
Expand All @@ -160,6 +160,12 @@ def cli_userinfo(
for cg in sorted(cgs, key=itemgetter("name")):
print(f"{cg['name']} [{cg['id']}]")

groups = userinfo.get("groups", None)
if groups:
print("Groups:")
for group in sorted(groups, key=lambda m: m.get("name")):
print(f" {group['name']} [{group['group_id']}]")

perms = userinfo.get("permissions", None)
if perms:
print("Permissions:")
Expand Down
18 changes: 18 additions & 0 deletions kojismokydingo/types.py
Expand Up @@ -103,6 +103,7 @@
"TaskInfo",
"TaskSpec",
"TaskState",
"UserGroup",
"UserInfo",
"UserSpec",
"UserStatus",
Expand Down Expand Up @@ -715,6 +716,20 @@ class DecoratedHostInfo(HostInfo):
DecoratedHostInfos = Iterable[DecoratedHostInfo]


class UserGroup(TypedDict):
"""
The results of the ``getUserGroups`` XMLRPC call
:since: 2.2.0
"""

group_id: int
""" the ID of the group """

name: str
""" the name of the group """


class UserInfo(TypedDict):
"""
Data representing a koji user account. These are typically
Expand Down Expand Up @@ -841,6 +856,9 @@ class DecoratedUserInfo(UserInfo):
members: List[UserInfo]
""" membership if user is a group """

groups: List[UserGroup]
""" groups that user is a member of """

statistics: Optional[UserStatistics]
""" user's interaction statistics """

Expand Down
84 changes: 67 additions & 17 deletions kojismokydingo/users.py
Expand Up @@ -27,10 +27,11 @@

from . import (
NoSuchContentGenerator, NoSuchPermission, NoSuchUser,
as_userinfo, bulk_load_users, )
as_userinfo, bulk_load_users, version_check, )
from .types import (
CGInfo, DecoratedPermInfo, DecoratedUserInfo, NamedCGInfo,
PermSpec, PermUser, UserInfo, UserSpec, UserStatistics, UserType, )
PermSpec, PermUser, UserInfo, UserSpec, UserStatistics,
UserGroup, UserType, )


__all__ = (
Expand All @@ -40,6 +41,7 @@
"collect_userinfo",
"collect_userstats",
"get_group_members",
"get_user_groups",
)


Expand Down Expand Up @@ -104,9 +106,40 @@ def collect_userstats(
return cast(UserStatistics, stats)


def get_user_groups(
session: ClientSession,
user: UserSpec) -> List[UserGroup]:
"""
Identify groups that a user is a member of
:param session: an active koji client session
:param user: name or ID of a potential member
:returns: list of groups that user is a member of
:raises NoSuchUser: if user could not be found
:since: 2.2
"""

user = as_userinfo(session, user)
uid = user["id"]

if version_check(session, (1, 35)):
# added in 1.35
return session.getUserGroups(uid) or []

else:
hist = session.queryHistory(tables=["user_groups"], active=True)
return [{"name": g["group.name"], "group_id": g["group_id"]}
for g in hist["user_groups"]
if g["user_id"] == uid]


def get_group_members(
session: ClientSession,
user: Union[int, str]) -> List[UserInfo]:
user: UserSpec) -> List[UserInfo]:

"""
An anonymous version of the admin-only ``getGroupMembers`` hub
Expand All @@ -117,36 +150,46 @@ def get_group_members(
:param user: name or ID of a user group
:returns: list of users that are members of the given group
:raises NoSuchUser: if no matching user group was found
:since: 2.2
"""

# getUserMembers returns a list of dicts, with keys: id,
# getGroupMembers returns a list of dicts, with keys: id,
# krb_principals, name, usertype. However, the call requires the
# admin permission
# admin permission prior to 1.35

# queryHistory is anonymous, and returns a dict mapping table to a
# list of events. In those events are keys: "user.name" and
# "user_id" which we can use to then lookup the rest of the
# information

try:
hist = session.queryHistory(tables=["user_groups"],
active=True, user=user)
except GenericError:
raise NoSuchUser(user)
user = as_userinfo(session, user)

uids = map(itemgetter("user_id"), hist["user_groups"])
found = bulk_load_users(session, uids, err=False)
if user["usertype"] != UserType.GROUP:
# we shortcut this because querying by a non-existent group ID
# causes hub to return all group memberships
return []

return list(filter(None, found.values()))
if version_check(session, (1, 35)):
return session.getGroupMembers(user["id"]) or []

else:
hist = session.queryHistory(tables=["user_groups"],
active=True, user=user["id"])

uids = map(itemgetter("user_id"), hist["user_groups"])
found = bulk_load_users(session, uids, err=False)
return list(filter(None, found.values()))


def collect_userinfo(
session: ClientSession,
user: UserSpec,
stats: bool = True) -> DecoratedUserInfo:
stats: bool = False,
members: bool = False) -> DecoratedUserInfo:
"""
Gather information about a named user, including the list of
permissions the user has.
Expand All @@ -159,10 +202,14 @@ def collect_userinfo(
:param user: name of a user or their kerberos ID
:param stats: collect user statistics
:param members: look up group members and memberships
:raises NoSuchUser: if user is an ID or name which cannot be
resolved
:since: 1.0
:since: 2.2
"""

userinfo = cast(DecoratedUserInfo, as_userinfo(session, user))
Expand All @@ -187,9 +234,12 @@ def collect_userinfo(
if stats:
userinfo["statistics"] = collect_userstats(session, userinfo)

if members:
userinfo["groups"] = get_user_groups(session, uid)

elif ut == UserType.GROUP:
# userinfo["members"] = session.getGroupMembers(uid)
userinfo["members"] = get_group_members(session, uid)
if members:
userinfo["members"] = get_group_members(session, uid)

return userinfo

Expand Down
7 changes: 6 additions & 1 deletion stubs/koji/__init__.pyi
Expand Up @@ -37,7 +37,7 @@ from kojismokydingo.types import (
ChannelInfo, CGInfo, HostInfo, ListTasksOptions, PackageInfo,
PermInfo, QueryOptions, RepoInfo, RepoState, RPMInfo, RPMSignature,
SearchResult, TagBuildInfo, TagInfo, TagGroupInfo, TagInheritance,
TagPackageInfo, TargetInfo, TaskInfo, UserInfo, )
TagPackageInfo, TargetInfo, TaskInfo, UserGroup, UserInfo, )


# === Globals ===
Expand Down Expand Up @@ -267,6 +267,11 @@ class ClientSession:
group: Union[int, str]) -> List[UserInfo]:
...

def getUserGroups(
self,
user: Union[int, str]) -> List[UserGroup]:
...

def getHost(
self,
hostInfo: Union[int, str],
Expand Down
18 changes: 17 additions & 1 deletion tests/cli/users.py
Expand Up @@ -53,8 +53,23 @@ def do_getUserPerms(userID = None):
else:
return []

def do_getUserGroups(userID = None):
if userID in (None, 100, "obrienc"):
return [{"name": "ldap/CoolGuys",
"group_id": 500,}]
else:
return []

def do_getGroupMembers(userID = None):
if userID == 500:
return [self.userinfo()]
else:
return []

sess.getUser.side_effect = do_getUser
sess.getUserPerms.side_effect = do_getUserPerms
sess.getUserGroups.side_effect = do_getUserGroups
sess.getKojiVersion.return_value = "1.35"

return sess

Expand All @@ -74,10 +89,11 @@ def test_cli_userinfo(self):
obriencj@PREOCCUPIED.NET
Type: NORMAL (user)
Status: NORMAL (enabled)
Groups:
ldap/CoolGuys [500]
Permissions:
coolguy
""")
print("\ntesting\n", expected)
self.assertEqual(expected, res[:len(expected)])


Expand Down

0 comments on commit 21395ee

Please sign in to comment.