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

WIP: Permission management #216

Closed
wants to merge 6 commits into from
Closed

WIP: Permission management #216

wants to merge 6 commits into from

Conversation

cutwater
Copy link
Contributor

No description provided.

"list": {"type": "queryset", "permission": "read"},
"create": {"type": "wildcard", "permission": "create"},
"retrieve": {"type": "object", "permission": "read"},
"update": {"type": "object", "permission": "edit"},
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater I think these 'edit' should be 'update'

scope=scope,
)
object_permission = models.AuthzPermission(
resource=make_resource_name(view.keycloak_resource_type, obj.pk),
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater This line raises an exception
Exception Value: | 'Job' object has no attribute 'pk'

The response from Share is a Job object which is going thru the permission model.

Screen Shot 2021-12-13 at 2 19 36 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The response doesn't go thru permission model. Can you please give more details on a request that ended up with this error.

ordering = ("-id",)
filterset_fields = ("name", "description", "created_at", "updated_at")
search_fields = ("name", "description")

keycloak_resource_type = "catalog:portfolio"
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater The PortfolioItem inherits all of its permission from the Portfolio object, so for PortfolioItem would be keycloak_resource_type be set to "catalog:portfolio" or would it be ansible_catalog.main.catalog.models.Portfolio?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For PortfolioItem the idea was to use keycloak_resource_type = "catalog:portfolio and override check_object_permissions method for the PortfolioItemViewSet:

class PortfolioItemViewSet(...):
    keycloak_resource_type = "catalog:portfolio"
    keycloak_access_policies = [...]

    def check_object_permissions(self, request, obj):
        return super().check_object_permissions(self, request, obj.portfolio)

But this will not work for list views. As they must check access for the individual portfolio as well.

scope=scope,
)
object_permission = models.AuthzPermission(
resource=make_resource_name(view.keycloak_resource_type, obj.pk),
Copy link
Contributor

@mkanoor mkanoor Dec 13, 2021

Choose a reason for hiding this comment

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

@cutwater instead of obj.pk we should be able to specify the attribute name to use. If the PermissionAttribute is not defined we can use pk. This relates to how we model Parent/Child Permissions.

Portfolio is the parent of PortfolioItem

Class PortfolioItem:
PermissionClass: Portfolio
PermissionAttribute: portfolio_id
KeycloakAccessPolicies: #we should be able to override the parents access policies

Class Portfolio:
PermissionClass: undefined defaults to class name
PermissionAttribute: undefined defaults to pk
KeycloakResourceType: catalog:portfolios
KeycloakAccessPolicies: ....

        In the query set we have model class name as either Portfolio or PortfolioItem

        access_token = request.keycloak_user.extra_data["access_token"]
        client = get_authz_client(access_token)
        permissions = client.get_permissions(
           models.AuthzPermission( scope=make_scope_name(PermissionClass.KeycloakResourceType, permission))
     )
       keycloak_ids = ... collect resource ids from permissions....
        key = f"{PermissionAttribute}__in"
        <<model_class_name>>.objects.filter(key=keycloak_ids)

        So for portfolio items the call would look like
        PortfolioItem.objects.filter(portfolio_id__in=keycloak_ids)
        For Portfolio the call would look like
        Portfolio.objects.filter(pk__in=keycloak_ids)

The permission class is different from the Model class

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Portfolio is the parent of PortfolioItem

Not really. They share one-to-many relationship in terms of models.

PortfolioItem.objects.filter(portfolio_id__in=keycloak_ids)

If user has access to Portfolio, he has access to all portfolio items, doesn't he?

Copy link
Contributor

@mkanoor mkanoor Dec 14, 2021

Choose a reason for hiding this comment

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

@cutwater We have an endpoint called /portfolio_items/ which lists all the visible PortfolioItem which is different from /portfolio/10/portfolio_items. And there are other direct access to portfolio_items which would have to know about the permissions on the Portfolio before granting access to users.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. Thank you.

# FIXME(cutwater): Support for non integer primary keys needed
resource_ids.append(int(resource_id))

return qs.filter(pk__in=resource_ids)
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater The pk__in should be customizable by the model class attributes.


class KeycloakPermission(BasePermission):
def has_permission(self, request: Request, view: Any):
if view.action is None and request.method in SAFE_METHODS:
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater The SAFE_METHODS have GET in them so should we be allowing a GET call to return True if there is no action, shouldn't it be False.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is needed mainly to support DRF generated html representations of the API. I think we may limit it to DEBUG=True.

scope=scope,
)
object_permission = models.AuthzPermission(
resource=make_resource_name(resource_type, obj.pk),
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Shouldn't this be portfolio_id when checking for PortfolioItem permissions. The attribute name would have to be either an attribute or it would have to be passed in. If we can define these as class level attributes it would simplify the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is already a portfolio_id, as portfolio object is passed to check_object_permissions() in the PortfolioItemViewSet, however I want to split this class into two and handle nestedviewsets in a separate permission class.

"update": {"type": "object", "permission": "update"},
"destroy": {"type": "object", "permission": "update"},
}

Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater If we can define the attribute name to use here. It might simplify the code it can be used in single object permission check as well as in the query set calls.

def _get_policy(view):
if view.action is None:
return None
return view.get_keycloak_access_policies().get(view.action)
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Should there be a check here if the action is not specified in the access_policies. So a developer could add it in if its missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the moment the default behavior is to deny access or return an empty queryset for a policy that is missing. How would you like to change that behavior?

if policy is None:
return False
if policy["type"] != OBJECT_PERMISSION:
return True
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Should this be returning False instead of True. Is this to prevent misconfigured access policies from giving access to object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, it shouldn't. This is to apply the policy only for a specific type.

@@ -0,0 +1,7 @@
class KeycloakPermissionMixin:

keycloak_resource_name = None
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Should this be keycloak_resource_type instead of keycloak_resource_name?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, indeed.

"destroy": {"type": "object", "permission": "delete"},
"share": {"type": "object", "permission": "update"},
"unshare": {"type": "object", "permission": "update"},
"share_info": {"type": "object", "permission": "update"},
Copy link
Contributor

@mkanoor mkanoor Dec 18, 2021

Choose a reason for hiding this comment

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

@cutwater I think the permission should be an array instead of a single value so we can explicitly state
share: :"type": "object", "permissions": ["read", "update"]

Otherwise the external package has to know that update means you have read permission. We are missing the copy action here, it order to copy you need read permission on the portfolio being copied and create permission to create the new portfolio object if you don't have either of these permissions it should get rejected.

"copy" : {"type": "object", permission: ["read","create"]}

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 think the permission should be an array instead of a single value so we can explicitly state
share: :"type": "object", "permissions": ["read", "update"]

Can you please provide an example where update permission does not imply read permission?

Otherwise the external package has to know that update means you have read permission.

What external "package"?

Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater I mean your code that is in ansible_catalog/common/auth/keycloak/authz.py at some point it would have to become an external package to be shared. The get_object call needs a "read" permission, if we don't list the read permission we are tying permissions together (update => read + update)

For the copy action we need read permission on the object we are trying to copy and then we need a create permission.

"copy" : {"type": "object", permission: ["read","create"]}

def get_keycloak_access_policies(self):
if "portfolio_id" in self.kwargs:
return {
"list": {"type": "object", "permission": "read"},
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater wouldn't this be resolved if permissions is an array and for object as well as query set we added "read"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope, because the permission is the same, the stage when permission check is applied is different.

@@ -313,6 +336,46 @@ class PortfolioItemViewSet(
search_fields = ("name", "description")
parent_field_names = ("portfolio",)

keycloak_resource_type = "catalog:portfolio"
keycloak_access_policies = {
"create": {"type": "object", "permission": "update"},
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Should this be using the constants defined

WILDCARD_PERMISSION = "wildcard"
 OBJECT_PERMISSION = "object"
 QUERYSET_PERMISSION = "queryset"

**self.keycloak_access_policies,
}

def check_permissions(self, request):
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks.

"retrieve": {"type": "object", "permission": "read"},
"update": {"type": "object", "permission": "update"},
"destroy": {"type": "object", "permission": "update"},
}
Copy link
Contributor

@mkanoor mkanoor Dec 18, 2021

Choose a reason for hiding this comment

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

@cutwater Don't we need a list action here since we can have /portfolio_items or /portfolio/nnn/portfolio_items/ we tweak the keycloak_access_polices based on the parent_id in the self.kwargs wouldn't it be better to have the list action be defined here. If we just fetched the parent object wouldn't that trigger its permission check to run automatically when its get_object is called. For nested routes we could just call the get_object on the parent if it exists. We are going to encounter this for order_items and progress_messages. /orders/nnn/order_items, /order_items/nnn/progress_messages

The base get_object call does a permission check
https://github.com/encode/django-rest-framework/blob/45082b39368729caa70534dde11b0788ef186a37/rest_framework/generics.py#L99

We can move all of the code that figures out the parentage call into a mixin and use a generic logic like here
https://github.com/chibisov/drf-extensions/blob/ecdf3a95d7f18ccf9cffa55809635c3715179605/rest_framework_extensions/mixins.py#L70

and make a get_object call on all the parent objects in the URL path and the get_object will call the appropriate permission checks. The get_object is defined in a View not sure how we would create a parent view to call the get_object. Can our keycloak_access_policies be moved to the model instead of views?

We have added this mixin for nested path which works for scenarios with and without parent_ids.
https://github.com/ansible/ansible-catalog/blob/1d7baa94e3d83f22d205c010b80da4fcf2a8de31/ansible_catalog/common/queryset_mixin.py#L26

For starters we can add the get_object call in this code and have it validate the permissions and this will work for all our uses cases of orders, order_items, progress messages etc.

Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater we can get the parent object here and check the permissions
https://github.com/ansible/ansible-catalog/blob/1d7baa94e3d83f22d205c010b80da4fcf2a8de31/ansible_catalog/common/queryset_mixin.py#L25

related_descriptor= getattr(queryset.model, parent_lookup_key)
related_model= related_descriptor.field.related_model
parent_obj = related_model.objects.get(pk=self.kwargs[parent_lookup_key])
... check permissions on parent object...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can our keycloak_access_policies be moved to the model instead of views?

Nope

ordering = ("-id",)
filterset_fields = ("name", "description", "created_at", "updated_at")
search_fields = ("name", "description")

keycloak_resource_type = "catalog:portfolio"
keycloak_access_policies = {
"list": {"type": "queryset", "permission": "read"},
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater The type seems to be redundant can we do that based on the action. If we have 2 special actions list and create this dictionaries becomes much simpler. List is the only time we need to call query_set otherwise it is a retrieve on a single object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Some ViewSets require queryset filtering (Actually only Portfolio and related ones), some of them don't as only wildcard check is performed.
  2. It is not true for custom actions, some actions have detail=True and some of them don't.
  3. It is very different for nested query sets.

I was thinking by determining type of policy based on view.detail property, but at the moment my preference is to be explicit on the policy type.

request: Request,
view: Any,
qs: QuerySet,
filter_field: str = "pk",
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater If we can use something like this
https://github.com/encode/django-rest-framework/blob/45082b39368729caa70534dde11b0788ef186a37/rest_framework/generics.py#L39

For portfolio we use
keycloak_lookup_field: pk
for portfolio_item we use
keycloak_lookup_field: portfolio_id

Then we don't have to pass the attribute name around it can be defined in the KeycloakPermissionMixin and be overriden by each class.

@@ -0,0 +1,7 @@
class KeycloakPermissionMixin:

Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater we could add the keycloak_lookup_field here and default it to pk
PortfolioItem would override it to portfolio_id

obj = get_object_or_404(
parent_model, **{lookup_field: lookup_value}
)
return _check_resource_permission(
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater Should this raise an exception exceptions.PermissionDenied instead of doing a return, because here we are only checking the permission on the parent. We might have to check permission on parent as well as the current object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure I'm following the issue here. Can you please elaborate?
The has_permission and has_object_permission methods return a boolean. By default if a permission check call returns True, the subsequent permission checks are executed, otherwise the permission denied response is returned to the client.

"list": KeycloakPolicy("read", KeycloakPolicyType.QUERYSET),
"share": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
"unshare": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
"share_info": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater we are missing copy here with ["read","create"] permissions

"retrieve": KeycloakPolicy("read", KeycloakPolicyType.OBJECT),
"create": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
"update": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
"destroy": KeycloakPolicy("update", KeycloakPolicyType.OBJECT),
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater We are missing copy action here with ["read","update"] permissions

}

def get_keycloak_access_policies(self):
if self.keycloak_lookup_field in self.kwargs:
Copy link
Contributor

Choose a reason for hiding this comment

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

@cutwater This function is confusing. Do we need this now since we are checking for parent object in has_object_permission and in has_permissions?

@cutwater cutwater closed this Jan 31, 2022
@cutwater cutwater deleted the feature/permissions branch March 10, 2022 10:02
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.

2 participants