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

has[Write|Read]Access does not use wildcard permissions #2353

Closed
chrisknoll opened this issue Feb 23, 2024 · 1 comment · Fixed by #2355
Closed

has[Write|Read]Access does not use wildcard permissions #2353

chrisknoll opened this issue Feb 23, 2024 · 1 comment · Fixed by #2355
Assignees
Milestone

Comments

@chrisknoll
Copy link
Collaborator

chrisknoll commented Feb 23, 2024

Expected behavior

Wildcard (*) permissions should be honored when checking read/write access.

Actual behavior

When checking entity permissions via hasWriteAccess or hasReadAccess it is checking the entitySchema (which is a string template of permissions for the given entity) for an exact string match for the specific entity ID. This means that permission granted to the user via wildcard permissions (such as a role that grants write permission to everything via *) is not honored.

Ie: checking cohortdefinition:1:get against a grant of cohortdefinitiion:*:get will fail, when the wildcard should allow any ID to pass.

Steps to reproduce behavior

This is a symptom of #2323 , in so far as the wildcard permission to cohortdefinition:*:get should be seen as read permission. and in that case only the specific cohortdefinition:{id}:get is recognized as the read permission. But this is a general symptom that we're not using WildCard permissions when checking perms in some cases but we are in other (such as URL checks).

Technical Considerations

As I was researching how permissions were applied, I was greatly confused by the approach taken in the code, and will try to describe it here so that the original devs (or people closer to the code) can comment and clarify.

The hasWriteAccess() is doing something particularly strange: I would have expected that in order to determine if you have write permissions, you get the set of permissions from getTemplatesForType() and just check that the user has all necessary permissions in order to determine if the user has WRITE access. However, the code builds a permission cache in preparePermissionCache() which does the following:

    public void preparePermissionCache(EntityType entityType, Set<String> permissionTemplates) {
        if (permissionCache.get().get(entityType) == null) {
            final ConcurrentHashMap<String, Set<RoleDTO>> rolesForEntity = new ConcurrentHashMap<>();
            permissionCache.get().put(entityType, rolesForEntity);

            List<String> permissionsSQLTemplates = permissionTemplates.stream()
                    .map(pt -> getPermissionSqlTemplate(pt))
                    .collect(Collectors.toList());

            Map<Long, RoleDTO> roleDTOMap = new HashMap<>();
            permissionsSQLTemplates.forEach(p -> {
                Iterable<PermissionEntity> permissionEntities = permissionRepository.findByValueLike(p, PERMISSION_ENTITY_GRAPH);
                for (PermissionEntity permissionEntity : permissionEntities) {
                    Set<RoleDTO> roles = rolesForEntity.get(permissionEntity.getValue());
                    if (roles == null) {
                        rolesForEntity.put(permissionEntity.getValue(), new HashSet<>());
                    }
                    Set<RoleDTO> cachedRoles = rolesForEntity.get(permissionEntity.getValue());
                    permissionEntity.getRolePermissions().forEach(rp -> {
                        RoleDTO roleDTO = roleDTOMap.get(rp.getRole().getId());
                        if (roleDTO == null) {
                            roleDTO = conversionService.convert(rp.getRole(), RoleDTO.class);
                            roleDTOMap.put(roleDTO.getId(), roleDTO);
                        }
                        cachedRoles.add(roleDTO);
                    });
                }
            });
        }
    }

in short: this will find all the roles that contain the specified permissions (from the template), and back in getRolesHavingPermissions it performs this work to return RoleDTOs that contain all the specified permissions in the template:

    public List<RoleDTO> getRolesHavingPermissions(EntityType entityType, Number id) {
        Set<String> permissionTemplates = getTemplatesForType(entityType, AccessType.WRITE).keySet();
        preparePermissionCache(entityType, permissionTemplates);

        List<String> permissions = permissionTemplates.stream()
                .map(pt -> getPermission(pt, id))
                .collect(Collectors.toList());
        int fitCount = permissions.size();
        Map<RoleDTO, Long> roleMap = permissions.stream()
                .filter(p -> permissionCache.get().get(entityType).get(p) != null)
                .flatMap(p -> permissionCache.get().get(entityType).get(p).stream())
                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
        List<RoleDTO> roles = roleMap.entrySet().stream()
                .filter(es -> es.getValue() == fitCount)
                .map(es -> es.getKey())
                .collect(Collectors.toList());
        return roles;
    }

Note: using WildCard semantics, it's possible a person may match 2 permissions for the same check (the * check and the literal {id} check), throwing off the 'fitCount' check which says we want the roles that have exactly the correct permissions.

Back in hasWriteAccess:

                } else {
                    EntityType entityType = entityPermissionSchemaResolver.getEntityType(entity.getClass());

                    List<RoleDTO> roles = getRolesHavingPermissions(entityType, entity.getId());

                    Collection<String> userRoles = authorizationInfo.getRoles();
                    hasAccess = roles.stream()
                            .anyMatch(r -> userRoles.stream()
                            .anyMatch(re -> re.equals(r.getName())));
                }

This block appears to be saying : ' if the user has any role that matches any role that has the required write permissions matches any role that is assigned to the user.

This seems like a wildly round-about way to answer the question 'do you have write access'. If write access is determined by if they have the required permission, a permission grant of * should match all 5 of the required permissions and thus grant the user TRUE for write access.

However, I may be missing some underlying concept of this logic that is not clear. For example, maybe associating back to the role-level permissions handles some corner case...or maybe it's faster to check a set of roles a user has compared to a set of permissions (although we still need to check all the permissions to find the roles these permissions belong to). It also seems to force that all permissions must be found in a single role in order to 'count', but I'm not sure why that needs to be the case here: If RoleA grants some the required WRITE perms, and RoleB grants the rest, wouldn't a person assigned to RoleA and RoleB then have write perms to the entity?

I would appreciate if @alex-odysseus and members of @OHDSI/odysseus could speak on these considerations because they were responsible for the initial implementation.

@chrisknoll chrisknoll self-assigned this Feb 23, 2024
chrisknoll added a commit that referenced this issue Feb 27, 2024
Changed read/write permission checks to use permissions instead of roles.
Permission checks are using Subject.isPermitted() which honors wildcard semantics.
Altered JwtAuthRealm to filter user permissions to either * or first element of permission to check for speed.
Changed permission index from JsonNode to Map<>.  Serializes same way, but map semantics are simpler to navigate.
Altered AuthrizationInfo to contain index of Permissions and store Wildcard perms.

General cleanup of unused imports and removed unused dependencies (ie: Autowired fields were removed if no longer needed).

Fixes #2353.
@chrisknoll
Copy link
Collaborator Author

In lieu of feedback on this issue, I've crated PR #2355 to address these concerns.

@rkboyce : can you please grab this branch and see if your global read (and other expected permission assignments) work correctly under this branch? Note; this will require the Atlas version that is currently on master.

chrisknoll added a commit that referenced this issue Apr 23, 2024
Permission checks are using Subject.isPermitted() which honors wildcard semantics.
Changed read/write permission checks to use permissions instead of roles.
Removed role cache from Permission Service.
Altered JwtAuthRealm to filter user permissions to either * or first element of permission to check for speed.
Changed permission index from JsonNode to Map<>.  Serializes same way, but map semantics are simpler to navigate.
Altered AuthrizationInfo to contain index of Permissions and store Wildcard perms.
General cleanup of unused imports and removed unused dependencies (ie: Autowired fields were removed if no longer needed).

Fixes #2353.

* Added test cases.
Reordered test scoped dependencies in pom.xml.
Refactored shared methods to AbstractDatabaseTest.
@anthonysena anthonysena added this to the v2.15 milestone May 28, 2024
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 a pull request may close this issue.

2 participants