- Notifications
You must be signed in to change notification settings - Fork 445
Permissions support for Workbooks, Datasources, Projects #292
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
Conversation
| Unit tests have been failing due to some other external thing. Last time I checked trunk is actually broken at the following unit test |
| @DepthDeluxe Great! I will take a look as soon as I can. Sadly hackathon has passed so this slips back into my 'after hours' time, but I'll do my best. A first comment that I have on a quick pass: I'm a known detractor of trying to subclass Endpoint, since I don't know what that might grow to in the future (TaggableEndpoint, EndpointWithPermissions, Etc). Is it possible to use composition, much like was one for tagging (31df99b)? For the test, the CI isn't failing on Update: This is going with a simple |
| @t8y8, I see your point. I believe this shouldn't be too bad to rework. Regarding your update, are you ok with the way permissions are organized? I could do something like put the |
2656a41 to d9130ca Compare | @t8y8, I have addressed the inheritance issue by replacing with composition. There is Let me know if there is anything else you want changed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for updating the PR!
I'll try to be more responsive to future PRs :)
I left several comments, some should be simple, others are questions or thoughts on alternatives to provoke discussion.
I think the endpoint design is good, just some cleanup and questions.
I don't like nesting the model as it currently stands, but I acknowledge that it's basically following the XML format. I will need to play with this and some old thoughts on the matter to propose any alternatives -- so in the mean time I commented on the code as it stands.
| def __init__(self, parent_srv): | ||
| super(Datasources, self).__init__(parent_srv) | ||
| self._resource_tagger = _ResourceTagger(parent_srv) | ||
| self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see your note about why this must be a lambda but I don't fully grok it yet. _ResourceTagger doesn't do this -- why is this endpoint different?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@t8y8, you need a lambda because you can't assign the parent baseurl on instantiation because it currently isn't known. It is only known once you sign in and the library determines what API to use.
This problem was solved separately in _ResourceTagger by taking in a baseurl every single time you call any member function. I didn't like this model because since the object is owned by a parent endpoint, its baseurl should be part of the object. I guess an alternative here could be requiring the parent to provide itself to this object's constructor, but I felt that produced unnecessary coupling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for reference, below is a snippet from _ResourceTagger
# Remove and add tags to match the resource item's tag set def update_tags(self, baseurl, resource_item): if resource_item.tags != resource_item._initial_tags: add_set = resource_item.tags - resource_item._initial_tags remove_set = resource_item._initial_tags - resource_item.tags for tag in remove_set: self._delete_tag(baseurl, resource_item.id, tag) if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) logger.info('Updated tags to {0}'.format(resource_item.tags)) As you can see, baseurl is a parameter to the member function
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, thank you! We may want to refactor _ResourceTagger to do the same #notetoself
| def _add_capability(self, parent_element, capability_set, mode): | ||
| for capability_item in capability_set: | ||
| def _add_capabilities(self, parent_element, capabilities): | ||
| for key in capabilities: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
key -> capability_name
value -> mode
Or something similar, just key and value are a little too generic for me
| item_element = ET.SubElement(permissions_element, permission_item.type) | ||
| item_element.attrib['id'] = permission_item.item_id | ||
| | ||
| for grantee_capability in permission_item.grantee_capabilities: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I appreciate the descriptive names, but I think the verbosity actually hurts a bit here. Probably because our code/xml has the longest most obtuse names possible :(
grantee_capability can just be 'capability', we know we're in the context of a grantee in the loop.
grantee_capabilities_element can probably safely be grantee_cap_element
| pass | ||
| | ||
| | ||
| class Permission: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the item that needs the most discussion, because getting the model API right is what most users will 'feel'.
I'm not sure yet about this approach, I need to write some sample scripts against it.
That said, some off-my-head comments are included to spur discussion. Feel free to take a new idea and run with it or disagree.
| | ||
| | ||
| class Permission: | ||
| class Type: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like this being represented in the model at all, where is it needed and can we get away with making this 'dumb' to the PermissionsModel and just call the object a 'resource' or something?
| Workbook = 'workbook' | ||
| Project = 'project' | ||
| | ||
| class GranteeType: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly, I don't like this being represented in the model at all, where is it needed and can we get away with making this 'dumb' to the PermissionsModel and just call the object a 'grantee' or something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing the difference between User permission and Group permission could be risky because users may not know what do do with the grantee_id field.
We could either subclass GranteeCapabilityItem (after renaming as mentioned above) to UserGranteeCapabilityItem and GroupGranteeCapabilityItem. Don't really like that since this is just kinda a data bag of an object. Instead, we could have two fields user_id and group_id, either of which are populated depending on what type the permission is for.
What do you think?
| Allow = 'Allow' | ||
| Deny = 'Deny' | ||
| | ||
| class DatasourceCapabilityType: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are pros and cons to splitting these out into their own namespaces -- discoverability and some safety, but it is enforced server idea already so this might just be extra code to maintain.
Still... I think this is probably fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@t8y8, the other option here is to implement permission classes for each type and have a factory method that instantiates the proper ones. So instead the API could be something like
workbook_capability.add_comment = True # for the "Allow" permission workbook_capability.change_permissions = False # for the "Deny" permission workbook_capability.delete = None # for the "empty" permission datasource_capability.export_xml = False ... There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like this design, optionally taking in kwarg pairs if you want a functional approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ping - we have an interest in this PR, wondering if it's still underway? What's remaining to do?
| Write = 'Write' | ||
| | ||
| | ||
| class GranteeCapabilityItem(object): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is where we had debate on an old approach -- I'd like to call this a Rule or PermissionsRule despite that not being the XML name. It's what the UI/Docs and humans tend to call "the combo of resource and grantee"
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems reasonable, GranteeCapabilityItem doesn't really mean anything to me
| permissions = PermissionsItem() | ||
| parsed_response = ET.fromstring(resp) | ||
| | ||
| for option in ('workbook', 'datasource', 'project'): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm considering a different way to parse this... but for now can you pull the list of permission_type options into a constant.
You also don't need the 'break' statements at the end of the try
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, it looks like it might be possible to pull the first element within the parent but that could be risky across different versions. I can definitely pull into a constant.
| except AttributeError: | ||
| pass | ||
| | ||
| assert grantee_id is not None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Raise an exception and log the issue please
| @DepthDeluxe I played with some things a bit, and here is the high level API I like for setting permissions. It matches the conceptual model of the UI, and I think feels pretty clean. It's quite different from the underlying XML though: PermissionsRule(grantee : Union[UserItem, GroupItem], capabilities : Dict[Capability, Mode]) server.projects.update_permission(permissions : List[PermissionsRule]) cool_guy_permissions = {Perms.Caps.Read: Perms.Mode.Allow, Perms.Caps.Write: Perms.Mode.Allow} tyler_permissions = PermissionsRule(tyler_user_item, cool_guy_permissions) all_user_permissions = PermissionsRule(all_users_group, {Perms.Caps.Read: Perms.Mode.Deny}) permissions = [tyler_permissions, all_user_permissions]I need to think through delete a little more, but what do you think? EDIT: I thought through one final problem, which is how to inject the correct item attribute to get the The request factory/serializer would then be the only thing that needs to know how to combine them together. PermissionRules can be reused across types, which might be handy if you want to loop ove a bunch of workbooks and data sources, or something like that. That combining code is also reusable, just take the ‘type’ of a content item and its ID and add it into the xml. |
a412777 to fdb8e15 Compare | @DepthDeluxe Just wanted to ping you for your thoughts and see if you were still interested in working on this? We'd really love to get this ready to merge and fill the gap in the library :) |
| @t8y8 thanks for the ping, haven't been able to work on this as much as I'd like lately... I like your suggestion for the API, the thing I'm worried about is API call magnification if there are a lot of specific permissions set for a workbook (i.e. REST call to retrieve In regard to your edit, I believe that's the way it's done already, at least based on my understanding of what you are saying. For deletion, we would want the ability to easily delete a single permission along with easily deleting permissions for a At least for me the REST API is quirky in that you need to delete an |
| @DepthDeluxe I don't quite follow what you mean by call magnification, can you provide an example? We could lazy load attributes, we basically do that for anything that requires a I think a PermissionsRule is just your |
| @t8y8, yes I'm concerned about making a lot of requests. This wouldn't be the case if we kept the mapping based on IDs like below. where Also I'm leaving out an important thing here: my primary use-case is an automated scanning process that will check the I guess then the approach would be
Perhaps the structure should be more like this: This way we only get what we are looking for. Thus the class would be more like My gut feeling says that it would be nice to have a helper function to make it easy to get the actual |
| def add_req(self, item, permission_item): | ||
| xml_request = ET.Element('tsRequest') | ||
| permissions_element = ET.SubElement(xml_request, 'permissions') | ||
| item_element = ET.SubElement(permissions_element, type(item).__name__.rstrip('Item').lower()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@t8y8 this little bit is terrible, looking for feedback here.
Trying to get the item type (Workbook, Datasource, Project) without having to explicitly send it every time. You had previously mentioned you want the Permissions module to be dumb to the type of . Figuring that if the function accepts item_type, then we will need to create an Enum for that which goes against your recommendation.
TBH it might be nice having each of the WorkbookItem, DatasourceItem, etc... to have an enum/type string associated with them so you could just use item.type instead of this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about this over the weekend. Given no changes, I'd prefer explicit 'isinstance' checks because at least we're not relying on the name but that's not much better.
I think including a .type str or enum on the ModelItems is better. Maybe call it _xml_type if just to be extra conservative.
| @t8y8, I have done another draft of the API implementation. Simplified naming conventions and the way that capabilities are stored. I also addressed some of the above comments in particular places where the code didn't change. Please let me know what you think. |
| Thanks, I will take a look as soon as aI can, definitely this week. |
| Pinging myself -- Haven't forgotten you. |
* _PermissionsEndpoint to be used inside Endpoint that has the calls * Added a couple of classes related to permissions, followed naming convention of API
| @t8y8 bumping, did you forget me now? 😭 |
| I have not, just flooded with the 'day job' :( Would you be willing to give me 'push' on your fork? I'd love to clone it and play around with a few scripts and try and collaborate there before merging back in here? |
| @t8y8 no problem, been there 😅 I have granted you collaboration access to my repo, feel free to play around with it. |
| any news on this feature? |
| @DepthDeluxe thanks! I'm playing around with it, sorry this is taking time... you know how open source goes :/ |
| @t8y8 any updates? |
| self._capabilities = capabilities | ||
| | ||
| @property | ||
| def capabilities(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a capability setter would be nice to enable user defined permission changes
| self.owner_baseurl = owner_baseurl | ||
| | ||
| def update(self, item, permission_item): | ||
| url = '{0}/{1}/permissions'.format(self.owner_baseurl(), item.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
project permissions definitions also includes default permissions for workbook and datasources access, it would be nice to have an url separation e.g. url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), item.id, default) (where default is 'workbooks' or 'datasources')
| @DepthDeluxe Hey! Based on your 'development' branch, I believe this is pretty close to what you have.
This logic to construct your list of rules is currently in the parser, and I think that should maybe move to business logic of the PermissionsCollection class but I haven't really cracked that yet, it just took me a long time to figure out how the dict was getting built. Open to ideas there! So overall, I actually think this is close, and I have some ideas for how to move forward quickly:
|
| @DepthDeluxe I tried pushing my in-progress tweaks to your fork in a new branch, but it says I'm still denied push |
| @t8y8 it looks like you never accepted the original invite. It seems like you might be sent the link to your email? Something like that. Anyways I have canceled the invite and sent it again. |
| @t8y8 I think what you are suggesting is definitely close to what I already have and seems good. I would be open to a call sometime this week to discuss details if necessary. Send me an email at colin@heinzmann.me to organize the call. |
| @t8y8 Made some extensive additions around permissions. Closing this old pull request. |
Addresses #41
@t8y8 previously commented in the conversation.
My approach here was to minimize the duplication of code as Projects, Datasources, and Workbooks all have the same remote access model. The two bigger classes,
PermissionsItemandEndpointWithPermissionsare more like mixins which add the functionality.Other classes added are
GranteeCapabilityItemwhich stores the actual permissions for a single group andPermissionwhich stores all the constants.I tried to closely follow the way the data and naming conventions are represented in the API. One thing to note: group permissions and user permissions are stored in the same list. The previous partial implementation suggested that we keep user and group permissions separate. I wanted to keep them together to match the API. In addition, the permissions collection is a list so there is a chance for users of this client library might have two separate
GranteeCapabilityItems for the same entity.I also added a couple of test cases to validate reading. I tested reading permissions and updating permissions for both users and groups on a Tableau Server 10.1 deployment.