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

Optimize permission lookups for a user #10906

Open
wants to merge 20 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
The following API have been added:
Copy link
Member

Choose a reason for hiding this comment

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

Once we've finalized the name of the API endpoint, please add it to the API Guide.


/api/users/{identifier}/allowedcollections/{permission}
Copy link
Member

Choose a reason for hiding this comment

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

I can live with this name. I would probably camel case it as allowedCollections.

How would we extend this API to datasets or files, if we needed to? Like below?

/api/users/{identifier}/allowedDatasets/{permission}
/api/users/{identifier}/allowedFiles/{permission}

Just playing around below, maybe instead we could have...

/api/users/{identifier}/permissions/collections/{permission}
/api/users/{identifier}/permissions/datasets/{permission}
/api/users/{identifier}/permissions/files/{permission}

? You might want to get some other opinions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the top APIs better. I think the allowedCollections etc. is more descriptive of what is being returned.

@qqmyers @scolapasta Any comment :)

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm fine with either set of names.


This API lists the dataverses/collections that the user has access to via the permission passed.
Copy link
Member

Choose a reason for hiding this comment

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

What about cases where the user is granted access at runtime based on their membership in Shibboleth groups or IP groups?

We have this related comment in the code:

/**
 * We don't expect this to support Shibboleth groups because even though
 * a Shibboleth user can have an API token the transient
 * shibIdentityProvider String on AuthenticatedUser is only set when a
 * SAML assertion is made at runtime via the browser.
 */

On a related note, that comment is above the call to this method on ServiceDocumentManagerImpl

public List<Dataverse> getDataversesUserHasPermissionOn(AuthenticatedUser user, Permission permission)

Should this method be replaced by the new one in this PR:

public List<Dataverse> findPermittedCollections(AuthenticatedUser user, int permissionBit)

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll swap it out and test it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@pdurbin I added IP Group support but unfortunately ServiceDocumentManagerImpl doesn't have access to the request and therefore the ip address of the caller. Unless you know of a way to get it?

Copy link
Member

Choose a reason for hiding this comment

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

I don't see an obvious way. However, the SWORD API has never supported IP Groups and to my knowledge nobody has asked for it, so I think it's ok.

The main use case for IP Groups is read-only access, such as walking into a library and having access to data because you are on the library's IP range.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I found a way to pass the IPAddress from the request made to Sword.

By passing "any" as the permission the list will return all dataverse/collections that the user can access regardless of which permission is used.
This API can be executed only by the User requesting their own list of accessible collections or by an Administrator.
Valid Permissions are: AddDataverse, AddDataset, ViewUnpublishedDataverse, ViewUnpublishedDataset, DownloadFile, EditDataverse, EditDataset, ManageDataversePermissions,
ManageDatasetPermissions, ManageFilePermissions, PublishDataverse, PublishDataset, DeleteDataverse, DeleteDatasetDraft, and "any" as a wildcard option.
Comment on lines +8 to +9
Copy link
Member

Choose a reason for hiding this comment

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

It's fine to list these here but it might be nice to have an API to list all these permissions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wouldn't have bother listing them in the release note but I wanted to point out the "any" permission for a wildcard. But since this wasn't asked for maybe we just leave it out of the release notes?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe, but do you think it's potentially useful to list these permissions via API? Maybe not for this PR but in the future.

The permissions should probably be listed in the guides for now. That way, the release note could link to them.

Copy link
Contributor Author

@stevenwinship stevenwinship Oct 4, 2024

Choose a reason for hiding this comment

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

An API would be nice. There is already a way to list the permissions in a role for the UI so adding a simple list of all permissions should be a quick add.

21 changes: 21 additions & 0 deletions doc/sphinx-guides/source/api/native-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6012,6 +6012,27 @@ Example: List permissions a user (based on API Token used) has on a dataset whos

curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/admin/permissions/:persistentId?persistentId=$PERSISTENT_IDENTIFIER"

List Dataverse collections a user can act on based on their permissions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

List Dataverse collections a user can act on based on a particular permission ::

GET http://$SERVER/api/users/$identifier/allowedCollections/$permission

.. note:: This API can only be called by an Administrator or by a User requesting their own list of accessible collections.

The ``$identifier`` is the username of the requested user.
The ``$permission`` is the permission (tied to the roles) that gives the user access to the collection.
Passing ``$permission`` as 'any' will return the collection as long as the user has any access/permission on the collection

.. code-block:: bash

export SERVER_URL=https://demo.dataverse.org
export $USERNAME=jsmith
export PERMISSION=PublishDataverse

curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/users/$USERNAME/allowedCollections/$PERMISSION"

Show Role Assignee
~~~~~~~~~~~~~~~~~~

Expand Down
132 changes: 99 additions & 33 deletions src/main/java/edu/harvard/iq/dataverse/PermissionServiceBean.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.DataverseRole;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IPv4Address;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IPv6Address;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress;
import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUserServiceBean;
import edu.harvard.iq.dataverse.authorization.users.GuestUser;
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.RoleAssignee;
import edu.harvard.iq.dataverse.authorization.groups.Group;
import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean;
import edu.harvard.iq.dataverse.authorization.groups.GroupUtil;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.engine.command.Command;
Expand Down Expand Up @@ -37,7 +38,6 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.logging.Level;
import java.util.stream.Collectors;
import static java.util.stream.Collectors.toList;
import jakarta.persistence.Query;
Expand Down Expand Up @@ -100,6 +100,60 @@ public class PermissionServiceBean {
@Inject
DatasetVersionFilesServiceBean datasetVersionFilesServiceBean;

private static final String LIST_ALL_DATAVERSES_USER_HAS_PERMISSION = """
WITH grouplist AS (
SELECT explicitgroup_authenticateduser.explicitgroup_id as id FROM explicitgroup_authenticateduser
WHERE explicitgroup_authenticateduser.containedauthenticatedusers_id = @USERID
)

SELECT * FROM DATAVERSE WHERE id IN (
SELECT definitionpoint_id
FROM roleassignment
WHERE roleassignment.assigneeidentifier IN (
SELECT CONCAT('&explicit/', explicitgroup.groupalias) as assignee
FROM explicitgroup
WHERE explicitgroup.id IN (
(
SELECT explicitgroup.id id
FROM explicitgroup
WHERE EXISTS (SELECT id FROM grouplist WHERE id = explicitgroup.id)
) UNION (
SELECT explicitgroup_explicitgroup.containedexplicitgroups_id id
FROM explicitgroup_explicitgroup
WHERE EXISTS (SELECT id FROM grouplist WHERE id = explicitgroup_explicitgroup.explicitgroup_id)
AND EXISTS (SELECT id FROM dataverserole
WHERE dataverserole.id = roleassignment.role_id AND (dataverserole.permissionbits & @PERMISSIONBIT !=0))
)
)
) UNION (
SELECT definitionpoint_id
FROM roleassignment
WHERE roleassignment.assigneeidentifier = (
SELECT CONCAT('@', authenticateduser.useridentifier)
FROM authenticateduser
WHERE authenticateduser.id = @USERID)
AND EXISTS (SELECT id FROM dataverserole
WHERE dataverserole.id = roleassignment.role_id AND (dataverserole.permissionbits & @PERMISSIONBIT !=0))
) UNION (
SELECT definitionpoint_id
FROM roleassignment
WHERE roleassignment.assigneeidentifier = ':authenticated-users'
AND EXISTS (SELECT id FROM dataverserole
WHERE dataverserole.id = roleassignment.role_id AND (dataverserole.permissionbits & @PERMISSIONBIT !=0))
) UNION (
SELECT definitionpoint_id
FROM roleassignment
WHERE roleassignment.assigneeidentifier IN (
SELECT CONCAT('&ip/', persistedglobalgroup.persistedgroupalias) as assignee
FROM persistedglobalgroup
LEFT OUTER JOIN ipv4range ON persistedglobalgroup.id = ipv4range.owner_id
LEFT OUTER JOIN ipv6range ON persistedglobalgroup.id = ipv6range.owner_id
WHERE dtype = 'IpGroup'
AND @IPRANGESQL
)
)
)
""";
/**
* A request-level permission query (e.g includes IP ras).
*/
Expand Down Expand Up @@ -553,36 +607,6 @@ public RequestPermissionQuery request(DataverseRequest req) {
return new RequestPermissionQuery(null, req);
}

/**
* Go from (User, Permission) to a list of Dataverse objects that the user
has the permission on.
*
* @param user
* @param permission
* @return The list of dataverses {@code user} has permission
{@code permission} on.
*/
public List<Dataverse> getDataversesUserHasPermissionOn(AuthenticatedUser user, Permission permission) {
Set<Group> groups = groupService.groupsFor(user);
String identifiers = GroupUtil.getAllIdentifiersForUser(user, groups);
/**
* @todo Are there any strings in identifiers that would break this SQL
* query?
*/
String query = "SELECT id FROM dvobject WHERE dtype = 'Dataverse' and id in (select definitionpoint_id from roleassignment where assigneeidentifier in (" + identifiers + "));";
logger.log(Level.FINE, "query: {0}", query);
Query nativeQuery = em.createNativeQuery(query);
List<Integer> dataverseIdsToCheck = nativeQuery.getResultList();
List<Dataverse> dataversesUserHasPermissionOn = new LinkedList<>();
for (int dvIdAsInt : dataverseIdsToCheck) {
Dataverse dataverse = dataverseService.find(Long.valueOf(dvIdAsInt));
if (userOn(user, dataverse).has(permission)) {
dataversesUserHasPermissionOn.add(dataverse);
}
}
return dataversesUserHasPermissionOn;
}

public List<AuthenticatedUser> getUsersWithPermissionOn(Permission permission, DvObject dvo) {
List<AuthenticatedUser> usersHasPermissionOn = new LinkedList<>();
Set<RoleAssignment> ras = roleService.rolesAssignments(dvo);
Expand Down Expand Up @@ -888,4 +912,46 @@ private boolean hasUnrestrictedReleasedFiles(DatasetVersion targetDatasetVersion
Long result = em.createQuery(criteriaQuery).getSingleResult();
return result > 0;
}

public List<Dataverse> findPermittedCollections(DataverseRequest request, AuthenticatedUser user, Permission permission) {
return findPermittedCollections(request, user, 1 << permission.ordinal());
}
public List<Dataverse> findPermittedCollections(DataverseRequest request, AuthenticatedUser user, int permissionBit) {
if (user != null) {
// IP Group - Only check IP if a User is calling for themself
String ipRangeSQL = "FALSE";
if (request != null
&& request.getAuthenticatedUser() != null
&& request.getSourceAddress() != null
&& request.getAuthenticatedUser().getUserIdentifier().equalsIgnoreCase(user.getUserIdentifier())) {
IpAddress ip = request.getSourceAddress();
if (ip instanceof IPv4Address) {
IPv4Address ipv4 = (IPv4Address) ip;
ipRangeSQL = ipv4.toBigInteger() + " BETWEEN ipv4range.bottomaslong AND ipv4range.topaslong";
} else if (ip instanceof IPv6Address) {
IPv6Address ipv6 = (IPv6Address) ip;
long[] vals = ipv6.toLongArray();
if (vals.length == 4) {
ipRangeSQL = """
(@0 BETWEEN ipv6range.bottoma AND ipv6range.topa
AND @1 BETWEEN ipv6range.bottomb AND ipv6range.topb
AND @2 BETWEEN ipv6range.bottomc AND ipv6range.topc
AND @3 BETWEEN ipv6range.bottomd AND ipv6range.topd)
""";
for (int i = 0; i < vals.length; i++) {
ipRangeSQL = ipRangeSQL.replace("@" + i, String.valueOf(vals[i]));
}
}
}
}

String sqlCode = LIST_ALL_DATAVERSES_USER_HAS_PERMISSION
.replace("@USERID", String.valueOf(user.getId()))
.replace("@PERMISSIONBIT", String.valueOf(permissionBit))
.replace("@IPRANGESQL", ipRangeSQL);
return em.createNativeQuery(sqlCode, Dataverse.class).getResultList();
}
return null;
}
}

24 changes: 23 additions & 1 deletion src/main/java/edu/harvard/iq/dataverse/api/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.engine.command.impl.ChangeUserIdentifierCommand;
import edu.harvard.iq.dataverse.engine.command.impl.GetUserPermittedCollectionsCommand;
import edu.harvard.iq.dataverse.engine.command.impl.GetUserTracesCommand;
import edu.harvard.iq.dataverse.engine.command.impl.MergeInAccountCommand;
import edu.harvard.iq.dataverse.engine.command.impl.RevokeAllRolesCommand;
Expand Down Expand Up @@ -260,5 +261,26 @@ public Response getTracesElement(@Context ContainerRequestContext crc, @Context
return ex.getResponse();
}
}

@GET
@AuthRequired
@Path("{identifier}/allowedCollections/{permission}")
@Produces("application/json")
public Response getUserPermittedCollections(@Context ContainerRequestContext crc, @Context Request req, @PathParam("identifier") String identifier, @PathParam("permission") String permission) {
AuthenticatedUser authenticatedUser = null;
try {
authenticatedUser = getRequestAuthenticatedUserOrDie(crc);
if (!authenticatedUser.getUserIdentifier().equalsIgnoreCase(identifier) && !authenticatedUser.isSuperuser()) {
return error(Response.Status.FORBIDDEN, "This API call can be used by Users getting there own permitted collections or by superusers.");
}
} catch (WrappedResponse ex) {
return error(Response.Status.UNAUTHORIZED, "Authentication is required.");
}
try {
AuthenticatedUser userToQuery = authSvc.getAuthenticatedUser(identifier);
JsonObjectBuilder jsonObj = execCommand(new GetUserPermittedCollectionsCommand(createDataverseRequest(getRequestUser(crc)), userToQuery, permission));
return ok(jsonObj);
} catch (WrappedResponse ex) {
return ex.getResponse();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package edu.harvard.iq.dataverse.api.datadeposit;

import java.io.IOException;

import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress;
import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -29,6 +31,13 @@ public void init() throws ServletException {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String ipAddress = req.getHeader("X-FORWARDED-FOR");
stevenwinship marked this conversation as resolved.
Show resolved Hide resolved
if (ipAddress == null) {
ipAddress = req.getRemoteAddr();
}
if (ipAddress != null) {
serviceDocumentManagerImpl.setIpAddress(IpAddress.valueOf(ipAddress));
}
this.api.get(req, resp);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import edu.harvard.iq.dataverse.DataverseServiceBean;
import edu.harvard.iq.dataverse.PermissionServiceBean;
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.engine.command.DataverseRequest;
import edu.harvard.iq.dataverse.util.SystemConfig;
import java.util.List;
import java.util.logging.Logger;
Expand Down Expand Up @@ -37,6 +39,8 @@ public class ServiceDocumentManagerImpl implements ServiceDocumentManager {
@Inject
UrlManager urlManager;

private IpAddress ipAddress = null;

@Override
public ServiceDocument getServiceDocument(String sdUri, AuthCredentials authCredentials, SwordConfiguration config)
throws SwordError, SwordServerException, SwordAuthException {
Expand Down Expand Up @@ -65,7 +69,7 @@ public ServiceDocument getServiceDocument(String sdUri, AuthCredentials authCred
* shibIdentityProvider String on AuthenticatedUser is only set when a
* SAML assertion is made at runtime via the browser.
*/
List<Dataverse> dataverses = permissionService.getDataversesUserHasPermissionOn(user, Permission.AddDataset);
List<Dataverse> dataverses = permissionService.findPermittedCollections(new DataverseRequest(user, ipAddress), user, Permission.AddDataset);
for (Dataverse dataverse : dataverses) {
String dvAlias = dataverse.getAlias();
if (dvAlias != null && !dvAlias.isEmpty()) {
Expand All @@ -82,4 +86,7 @@ public ServiceDocument getServiceDocument(String sdUri, AuthCredentials authCred
return service;
}

public void setIpAddress(IpAddress ipAddress) {
this.ipAddress = ipAddress;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package edu.harvard.iq.dataverse.engine.command.impl;

import edu.harvard.iq.dataverse.Dataverse;
import edu.harvard.iq.dataverse.DvObject;
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.engine.command.AbstractCommand;
import edu.harvard.iq.dataverse.engine.command.CommandContext;
import edu.harvard.iq.dataverse.engine.command.DataverseRequest;
import edu.harvard.iq.dataverse.engine.command.RequiredPermissions;
import edu.harvard.iq.dataverse.engine.command.exception.CommandException;
import jakarta.json.Json;
import jakarta.json.JsonArrayBuilder;
import jakarta.json.JsonObjectBuilder;

import java.util.List;
import java.util.logging.Logger;

import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json;

@RequiredPermissions({})
public class GetUserPermittedCollectionsCommand extends AbstractCommand<JsonObjectBuilder> {
private static final Logger logger = Logger.getLogger(GetUserPermittedCollectionsCommand.class.getCanonicalName());

private DataverseRequest request;
private AuthenticatedUser user;
private String permission;
public GetUserPermittedCollectionsCommand(DataverseRequest request, AuthenticatedUser user, String permission) {
super(request, (DvObject) null);
this.request = request;
this.user = user;
this.permission = permission;
}

@Override
public JsonObjectBuilder execute(CommandContext ctxt) throws CommandException {
if (user == null) {
throw new CommandException("User not found.", this);
}
int permissionBit;
try {
permissionBit = permission.equalsIgnoreCase("any") ?
Integer.MAX_VALUE : (1 << Permission.valueOf(permission).ordinal());
} catch (IllegalArgumentException e) {
throw new CommandException("Permission not valid.", this);
}
List<Dataverse> collections = ctxt.permissions().findPermittedCollections(request, user, permissionBit);
if (collections != null) {
JsonObjectBuilder job = Json.createObjectBuilder();
JsonArrayBuilder jab = Json.createArrayBuilder();
for (Dataverse dv : collections) {
jab.add(json(dv));
}
job.add("count", collections.size());
job.add("items", jab);
return job;
}
return null;
}
}
Loading
Loading