Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: get_group_content() lets you get content that groups can access [draft, tests forthcoming] #337

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

toph-allen
Copy link
Collaborator

@toph-allen toph-allen commented Nov 21, 2024

Intent

Add a function to support getting content that a group has access to.

Fixes #334

Approach

The new function, get_group_content(), requires a src argument and groups, which can either be a data frame with guid and name columns or a character vector of GUIDs.

client <- connect()
groups <- get_groups(client)

# one group
groups %>%
  filter(name == "research") %>%
  get_group_content(client, .)

# all groups
get_group_content(client, groups)

# use the GUID only
get_group_content(client, groups$guid)

This is slightly different from similar functions like get_group_members(client, group_guid), which requires only the guid. Reasons for the difference:

  • Just passing in the group_guid is marginally simpler for the user for getting one group's content (just select the row, don't need to pull the column), and significantly simpler for getting multiple groups (just pass in a data frame with multiple rows). Landed upon this approach & reasoning rubber-ducking with @schloerke earlier last week.
  • It allows us to include group_name in the returned data frame with less overhead. The downside of this is, to keep our return shape consistent, we need to look up the group_name for each group if passed a bare character vector of GUIDs.

Other notes

  • I moved all groups-related functions to a groups.R file.
  • Added a utility function to verify the schema of the input data frame. It seemed like the function was getting complex enough to factor it out anyway, and this pattern seems like a very useful one given that so many of our functions return data frames.

Checklist

  • Does this change update NEWS.md (referencing the connected issue if necessary)?
  • Does this change need documentation? Have you run devtools::document()?

@toph-allen toph-allen marked this pull request as draft November 21, 2024 22:37
@toph-allen
Copy link
Collaborator Author

This is still a draft, as I haven't finished writing tests yet. I'd still appreciate feedback on the direction.

R/groups.R Outdated Show resolved Hide resolved
R/groups.R Outdated Show resolved Hide resolved
@schloerke
Copy link
Collaborator

  • This is slightly different from similar functions like get_group_members(client, group_guid). Reasons for this difference are:

Just passing in the group_guid is marginally simpler for the user for getting one group's content (just select the row, don't need to pull the column), and significantly simpler for getting multiple groups (just pass in a data frame with multiple rows). Landed upon this approach & reasoning rubber-ducking with @schloerke earlier last week.

Both get_group_members() and get_group_content() could move towards accepting both src, groups where groups is either a single group_guid, a group_guid vector, or a groups data frame. Given both methods return a data frame, it would extend nicely. (get_group_members() would need to add the group_guid / group_name for consistency w/ get_group_content())

@toph-allen
Copy link
Collaborator Author

  • This is slightly different from similar functions like get_group_members(client, group_guid). Reasons for this difference are:

Just passing in the group_guid is marginally simpler for the user for getting one group's content (just select the row, don't need to pull the column), and significantly simpler for getting multiple groups (just pass in a data frame with multiple rows). Landed upon this approach & reasoning rubber-ducking with @schloerke earlier last week.

Both get_group_members() and get_group_content() could move towards accepting both src, groups where groups is either a single group_guid, a group_guid vector, or a groups data frame. Given both methods return a data frame, it would extend nicely. (get_group_members() would need to add the group_guid / group_name for consistency w/ get_group_content())

@schloerke How much do you think I should hesitate to change get_group_members() now because it breaks existing workflows? Like, should that change wait for a major release?

@toph-allen
Copy link
Collaborator Author

Marking this as ready for review. There are some areas that might be over-engineered! The function supports passing in bare guids as well as the groups data frame. If passed in just GUIDs, it calls the v1/groups endpoints for each to look up the name.

Originally I thought we needed the name to filter the permissions list, but I mistakenly thought that the principal_guid was different from the group_guid (it is the same). Thus, we really only need the name to include in the output data frame. I still think this is much nicer than just including the GUID, though, so I'd like to keep it.

In the future (or possibly now?) I'll update get_group_members to work similarly in terms of arguments and data returned. I've held off on changing it now to avoid breaking changes, but if folks think it's fine breaking things to make them better, I'll update it now.

@toph-allen toph-allen marked this pull request as ready for review November 22, 2024 18:54
Copy link
Contributor

@jonkeane jonkeane left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a higher level: do we need to have this dual-input setup where one can pass either guids or a data.frame? What happens if the data.frame passed has data that is contradictory a guid and name in the same row but those don't match what's on the server?

Copy link
Collaborator Author

@toph-allen toph-allen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added contextual comments for reviewers.

R/connect.R Outdated
Comment on lines 690 to 703
#' @description Get content to which a group has access
#' @param guid The group GUID.
group_content = function(guid) {
path <- v1_url("experimental", "groups", guid, "content")
self$GET(path)
},

#' @description Get the details for a group
#' @param guid The group GUID.
group_details = function(guid) {
path <- v1_url("groups", guid)
self$GET(path)
},

Copy link
Collaborator Author

@toph-allen toph-allen Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two new endpoints to support new group functionality. Makes me wonder if I should add a function to call the group details endpoint, to get the details from just the GUID. Probably not high priority for this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is group_details() called anywhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was previously, when I was looking up the group name for GUID-only invocations. But it's not now, so I've removed it.

R/groups.R Outdated
Comment on lines 1 to 106
#' @param limit The number of groups to retrieve before paging stops.
#'
#' `limit` will be ignored is `prefix` is not `NULL`.
#' To limit results when `prefix` is not `NULL`, change `page_size`.
#'
#' @return
#' A tibble with the following columns:
#'
#' * `guid`: The unique identifier of the group
#' * `name`: The group name
#' * `owner_guid`: The group owner's unique identifier. When using LDAP or
#' Proxied authentication with group provisioning enabled this property
#' will always be null.
#'
#' @details
#' Please see https://docs.posit.co/connect/api/#get-/v1/groups for more information.
#'
#' @examples
#' \dontrun{
#' library(connectapi)
#' client <- connect()
#'
#' # get all groups
#' get_groups(client, limit = Inf)
#' }
#'
#' @family groups functions
#' @export
get_groups <- function(src, page_size = 500, prefix = NULL, limit = Inf) {
validate_R6_class(src, "Connect")

# The `v1/groups` endpoint always returns the first page when `prefix` is
# specified, so the page_offset function, which increments until it hits an
# empty page, fails.
if (!is.null(prefix)) {
response <- src$groups(page_size = page_size, prefix = prefix)
res <- response$results
} else {
res <- page_offset(src, src$groups(page_size = page_size, prefix = NULL), limit = limit)
}

out <- parse_connectapi_typed(res, connectapi_ptypes$groups)

return(out)
}

#' Get users within a specific group
#'
#' @param src The source object
#' @param guid A group GUID identifier
#'
#' @return
#' A tibble with the following columns:
#'
#' * `email`: The user's email
#' * `username`: The user's username
#' * `first_name`: The user's first name
#' * `last_name`: The user's last name
#' * `user_role`: The user's role. It may have a value of administrator,
#' publisher or viewer.
#' * `created_time`: The timestamp (in RFC3339 format) when the user
#' was created in the Posit Connect server
#' * `updated_time`: The timestamp (in RFC3339 format) when the user
#' was last updated in the Posit Connect server
#' * `active_time`: The timestamp (in RFC3339 format) when the user
#' was last active on the Posit Connect server
#' * `confirmed`: When false, the created user must confirm their
#' account through an email. This feature is unique to password
#' authentication.
#' * `locked`: Whether or not the user is locked
#' * `guid`: The user's GUID, or unique identifier, in UUID RFC4122 format
#'
#' @details
#' Please see https://docs.posit.co/connect/api/#get-/v1/groups/-group_guid-/members
#' for more information.
#'
#' @examples
#' \dontrun{
#' library(connectapi)
#' client <- connect()
#'
#' # get the first 20 groups
#' groups <- get_groups(client)
#'
#' group_guid <- groups$guid[1]
#'
#' get_group_members(client, guid = group_guid)
#' }
#'
#' @family groups functions
#' @export
get_group_members <- function(src, guid) {
validate_R6_class(src, "Connect")

res <- src$group_members(guid)

out <- parse_connectapi(res$results)

return(out)
}
Copy link
Collaborator Author

@toph-allen toph-allen Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part of the file (get_groups() and get_group_members()) were just moved verbatim from get.R. I think it's probably more helpful to organize the code consistently by subject domain, and to move away from the paradigm of having most of the "get this type of object" functions in get.R.

R/groups.R Outdated
Comment on lines 108 to 205
#' Get content access permissions for a group or groups
#'
#' @param src The source object
#' @param groups Either a data frame of groups, or a character vector of group guids
#'
#' @return
#' A tibble with the following columns:

#' * `group_guid`: The group's GUID
#' * `group_name`: The group's name
#' * `content_guid`: The content item's GUID
#' * `content_name`: The content item's name
#' * `content_title`: The content item's title
#' * `access_type`: The access type of the content item ("all", "logged_in", or "acl")
#' * `role`: The access type that members of the group have to the
#' content item, "publisher" or "viewer".
#'
#' @examples
#' \dontrun{
#' library(connectapi)
#' client <- connect()
#'
#' # Get a data frame of groups
#' groups <- get_groups(client)
#'
#' # Get permissions for a single group by passing in the corresponding row.
#' get_group_content(client, groups[1, ])
#' dplyr::filter(groups, name = "research_scientists") %>%
#' get_group_content(client, groups = .)
#'
#' # Get permissions for all groups by passing in the entire groups data frame.
#' get_group_content(client, groups)
#'
#' # You can also pass in a guid or guids as a character vector.
#' get_group_content(client, groups$guid[1])
#' }
#'
#' @family groups functions
#' @export
#' @importFrom rlang .data
get_group_content <- function(src, groups) {
validate_R6_class(src, "Connect")
if (inherits(groups, "data.frame")) {
validate_df_ptype(groups, tibble::tibble(
guid = NA_character_,
name = NA_character_
))
} else if (inherits(groups, "character")) {
# If a character vector, we assume we are receiving group guids, and call
# the endpoint to fetch the group name.
groups <- purrr::map_dfr(groups, src$group_details)
} else {
stop("`groups` must be a data frame or character vector.")
}

purrr::pmap_dfr(
dplyr::select(groups, .data$guid, .data$name),
get_group_content_impl,
src = src
)
}

#' @importFrom rlang .data
get_group_content_impl <- function(src, guid, name) {
validate_R6_class(src, "Connect")

res <- src$group_content(guid)
parsed <- parse_connectapi_typed(res, connectapi_ptypes$group_content)

dplyr::transmute(parsed,
group_guid = guid,
group_name = name,
.data$content_guid,
.data$content_name,
.data$content_title,
.data$access_type,
role = purrr::map_chr(
.data$permissions,
extract_role,
principal_guid = guid
)
)
}

# Given the list of permissions for a content item, extract the role for the
# provided principal_guid
extract_role <- function(permissions, principal_guid) {
matched <- purrr::keep(
permissions,
~ .x[["principal_guid"]] == principal_guid
)
if (length(matched) == 1) {
return(matched[[1]][["principal_role"]])
} else {
stop("Unexpected permissions structure.")
}
stop(glue::glue("Could not find permissions for \"{principal_guid}\""))
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New functions that comprise the implementations of the get_group_content(), factored out slightly for clarity of code and testability.

Notes for reviewers here, which should probably become code comments:

  • The exported function, get_group_content(), handles input validation and normalization (to allow the use of both GUIDs and data frames), and calls get_group_content_impl() across all input groups.
  • get_group_content_impl() gets the content for a single group from the guid and name. It isn't exported because it is the strictly less flexible core of get_group_content().
  • extract_role() is factored out for unit tests. Probably also useful elsewhere.

Comment on lines +214 to +220
),
group_content = tibble::tibble(
content_guid = NA_character_,
content_name = NA_character_,
content_title = NA_character_,
access_type = NA_character_,
permissions = NA_list_
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For use with parse_connectapi_typed()

R/ptype.R Outdated
Comment on lines 223 to 251

# Validates an input data frame against a required schema ptype.
# 1. is a data frame or similar object;
# 2. contains all the names from the required;
# 3. that all matching names have the correct ptype.
validate_df_ptype <- function(input, required) {
if (!inherits(input, "data.frame")) {
stop("Input must be a data frame.")
}
if (!all(names(input) %in% required)) {
missing <- setdiff(names(required), names(input))
if (length(missing) > 0) {
stop(glue::glue("Missing required columns: {paste0(missing, collapse = ', ')}"))
}
}

for (col in names(required)) {
tryCatch(
vctrs::vec_ptype_common(input[[col]], required[[col]]),
error = function(e) {
stop(glue::glue(
"Column `{col}` has type `{vctrs::vec_ptype_abbr(input[[col]])}`; ",
"needs `{vctrs::vec_ptype_abbr(required[[col]])}:`\n",
conditionMessage(e)
))
}
)
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to allow us to easily validate data frames against a schema in functions, and to give decent user-interpretable error messages.

Comment on lines 6 to 11
Warning:
Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
i Please use `"guid"` instead of `.data$guid`
Warning:
Use of .data in tidyselect expressions was deprecated in tidyselect 1.2.0.
i Please use `"name"` instead of `.data$name`
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These warnings don't appear when I use the function from the command line. As far as I can tell, there are no other good options here:

  • Just using guid with no qualifications gives lint errors
  • Using "guid" fixes lint errors but changes the names of the resulting data frame to "guid" with quotation marks(!)

@schloerke
Copy link
Collaborator

  • This is slightly different from similar functions like get_group_members(client, group_guid). Reasons for this difference are:

Just passing in the group_guid is marginally simpler for the user for getting one group's content (just select the row, don't need to pull the column), and significantly simpler for getting multiple groups (just pass in a data frame with multiple rows). Landed upon this approach & reasoning rubber-ducking with @schloerke earlier last week.

Both get_group_members() and get_group_content() could move towards accepting both src, groups where groups is either a single group_guid, a group_guid vector, or a groups data frame. Given both methods return a data frame, it would extend nicely. (get_group_members() would need to add the group_guid / group_name for consistency w/ get_group_content())

@schloerke How much do you think I should hesitate to change get_group_members() now because it breaks existing workflows? Like, should that change wait for a major release?

I missed this from earlier. My fault.

https://github.com/search?q=get_group_members+language%3AR+&type=code shows there isn't any usage outside of Posit. The shape of the function isn't really changing. The second argument would still be guid compatible. I would feel comfortable renaming the variable as the typical usage does not involve naming the variable.

But it seems fair to just try this new style moving forward and when the day comes, we can catch up get_group_members() then.

@toph-allen
Copy link
Collaborator Author

toph-allen commented Nov 25, 2024

At a higher level: do we need to have this dual-input setup where one can pass either guids or a data.frame? What happens if the data.frame passed has data that is contradictory a guid and name in the same row but those don't match what's on the server?

@jonkeane I think it's really nice to be able to pass in a data frame; it means that you can directly use the output of get_groups() or a dplyr::filter() call on that output. It feels nice and idiomatic to pass in a data frame, and using the data in that data frame to populate group_name and group_guid columns of the resulting data frame feels like a natural way to allow the function to operate on multiple groups at once, giving the user a lot of flexibility with the returned data. In the Connect issue about the cookbook I sketched out a few possible approaches, and the data frame feels the best. And allowing a vector of GUIDs to also be used as the input makes the function behave in a way that also is compatible with the existing idioms of the package (at a performance penalty, because in that case the group_name is looked up on the server.

As to your second question: If the user passes in an arbitrary data frame with guid and name columns that don't refer to groups on the Connect server (maybe accidentally a data frame of content items), then the API calls to look up the group content via the guid won't work and the function will return an error.

If they pass in a data frame that has real guids from the Connect server but a nonsensical name column, those names would be used in the resulting data frame. I think that this is pretty unlikely, but it's definitely possible. They'd have to specifically construct that data frame using group guids and some other text for a name column.

There are a few ways we could avoid this, but they all involve some level of compromise:

  • Right now if the user passes in a vector of GUIDs, we look up the group_name for each GUID in a separate API call. This adds time and call overhead, but we could only use the guid column of the data frame that's passed in and discard all other columns.
  • We could remove group_name from the output, but this makes it harder to make sense of the results — even in the case of passing in one group GUID, but especially in the case of passing in multiple.

Another option, which I like less, would be to just implement this in a way that operates on a single group and does not include a name in the output.

P.S. Another upcoming story (I'm starting on it now) where rubber ducking about how it should feel led to passing in the returned data frame or a row from it rather than individual parameters: terminating jobs

@toph-allen
Copy link
Collaborator Author

  • This is slightly different from similar functions like get_group_members(client, group_guid). Reasons for this difference are:

Just passing in the group_guid is marginally simpler for the user for getting one group's content (just select the row, don't need to pull the column), and significantly simpler for getting multiple groups (just pass in a data frame with multiple rows). Landed upon this approach & reasoning rubber-ducking with @schloerke earlier last week.

Both get_group_members() and get_group_content() could move towards accepting both src, groups where groups is either a single group_guid, a group_guid vector, or a groups data frame. Given both methods return a data frame, it would extend nicely. (get_group_members() would need to add the group_guid / group_name for consistency w/ get_group_content())

@schloerke How much do you think I should hesitate to change get_group_members() now because it breaks existing workflows? Like, should that change wait for a major release?

I missed this from earlier. My fault.

https://github.com/search?q=get_group_members+language%3AR+&type=code shows there isn't any usage outside of Posit. The shape of the function isn't really changing. The second argument would still be guid compatible. I would feel comfortable renaming the variable as the typical usage does not involve naming the variable.

But it seems fair to just try this new style moving forward and when the day comes, we can catch up get_group_members() then.

@schloerke I imagine that most of the usage of connectapi will probably be in private repos, but good point. I'll see how other discussion around this issue resolves and think about this more. My personality makes me want to follow semantic versioning fairly strictly, and I also don't want to increment a major version for a single parameter change in one function, so I kinda was thinking of coalescing changes like this into larger releases, but I take your point that few people will be calling get_group_members(client, guid = "MY_GUID")

@jonkeane
Copy link
Contributor

@jonkeane I think it's really nice to be able to pass in a data frame; it means that you can directly use the output of get_groups() or a dplyr::filter() call on that output. It feels nice and idiomatic to pass in a data frame

If we only accepted GUIDs, you could still go from get_groups() to dplyr::filter() to get_group_content() you just need to pull() the GUID column.

and using the data in that data frame to populate group_name and group_guid columns of the resulting data frame feels like a natural way to allow the function to operate on multiple groups at once, giving the user a lot of flexibility with the returned data. In the https://github.com/rstudio/connect/issues/28849 about the cookbook I sketched out a few possible approaches, and the data frame feels the best. And allowing a vector of GUIDs to also be used as the input makes the function behave in a way that also is compatible with the existing idioms of the package (at a performance penalty, because in that case the group_name is looked up on the server.

I actually had missed in my first reading of the code that we are using the input data.frame to construct the output for group names. That's fishy — it opens the rest of the code up to having to deal with edge cases that we shouldn't (need to).

As to your second question: ...

nods yeah, those edge cases sound annoying to deal with. It might mostly work in most cases, but guarding against them is nontrivial (one example of this is in the PR itself: there's a non-trivial amount of guarding code that we have here, and if we push this forward we would likely need even more)

There are a few ways we could avoid this, but they all involve some level of compromise:

To be consistent: the approach of accepting data.frames also has compromises in the amount of guarding code we need to write + the edge cases we need to address when supporting that. Additionally, there is a documentation + user burden to having an argument that accepts two types and has slightly different behavior based on that. Sometimes it's worth it to do that anyway, but I don't think it is in this case.

Would it be possible to implement this PR as just accepting GUIDs? There are open questions about possible optimizations (getting group names) or alternative inputs (data.frames). But getting the simple case of GUIDs in a good state and merging that first would do us well here.

@toph-allen
Copy link
Collaborator Author

Ok, I can do that.

Copy link
Contributor

@jonkeane jonkeane left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much more minimal, which is a great 💯 . A few comments about reducing some of the nesting of functions

R/groups.R Outdated Show resolved Hide resolved
Comment on lines +173 to +179
permissions_df <- purrr::map_dfr(
parsed$permissions,
~ purrr::keep(
.x,
~ .x[["principal_guid"]] == guid
)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes through the permissions (list of data.frames) column and returns a dataframe that is the matching rows from that list, yeah?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

permissions is a list of lists… of lists, hence the nestedness and the use of purrr::keep.

  • At top level, each item is a content item i.e. a row of the data frame.
  • The second level (i.e. within each each row), each item is a principal — potentially multiple per content item.
  • In the third level, each item is a property of that principal permissions record (principal_name, principal_role, principal_guid, principal_type, etc.).

The API returns all the principals for the content item, so we have to filter for the correct principal for each content item.

R/groups.R Outdated
}

#' @importFrom rlang .data
get_group_content_impl <- function(src, guid) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this separate level of nested functions here still? I think it made more sense when there were a few more branches, but it feels unnecessary here.

I also wonder if we can reduce the number of (practical) loops we're going through. We call this in a purrr::map.. up above, and then there is at least one purrr::map... below which includes two anonymous functions which also practically go through each element checking for equality. These might all be necessary, but when I was trying to simplify this found the levels of nesting to be not-totally-obvious what was doing what where.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not sure. I found the distinction at this level to be helpful when thinking about the code — thinking about parsing each group's content into a data frame and then mapping that across map_dfr was helpful. But it would be possible to put this in an anonymous function call.

It isn't immediately obvious to me how to reduce the level of nestedness.

  • This level is necessary, because we receive multiple GUIDs, and each one requires some level of processing.
  • The inner processing is fairly nested because only the top level of the incoming JSON is parsed into a data frame, and the API returns a fairly nested structure for permissions (see below).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This level is necessary, because we receive multiple GUIDs, and each one requires some level of processing.

Maybe we could make the name of this function make this clearer? If it were something like get_one_group_content that makes it a bit clearer that it's getting content for a single guid?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I like that change.

R/connect.R Outdated
Comment on lines 690 to 703
#' @description Get content to which a group has access
#' @param guid The group GUID.
group_content = function(guid) {
path <- v1_url("experimental", "groups", guid, "content")
self$GET(path)
},

#' @description Get the details for a group
#' @param guid The group GUID.
group_details = function(guid) {
path <- v1_url("groups", guid)
self$GET(path)
},

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is group_details() called anywhere?

Co-authored-by: Jonathan Keane <jkeane@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feat: support getting the content a group has access to
3 participants