Skip to content

Commit 181be3c

Browse files
committed
update wb v1 auth payload to include intention
* When requesting auth from the OSF, WB includes metadata about the requested action. This metadata had been overloaded to mean both "what is the user trying to do" and "what permissions does the user need to do it". In the past it was easy to infer the latter from the former, but with the addition of osfstorage quotas, that is no longer the case. The overloading also made it very confusing to reason about both the OSF and WB sides of this request. Factor out this code into a new method, `_determine_actions`, and update the auth payload to include the intent separate from the permissions. * Update v1's movecopy to pass `path` to the auth object's `fetch` method. Everyone else does it, why shouldn't they? * Update tests for new permissions & intent fields. [ENG-2249]
1 parent c8407a3 commit 181be3c

File tree

3 files changed

+146
-50
lines changed

3 files changed

+146
-50
lines changed

tests/auth/osf/test_handler.py

+45-19
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ async def test_permissions_post_copy_source_destination(self):
8181
'nid': 'test',
8282
'provider': 'test',
8383
'action': 'download',
84+
'intent': 'copyfrom',
8485
'path': '',
8586
'version': None,
8687
'metrics': {
@@ -95,6 +96,7 @@ async def test_permissions_post_copy_source_destination(self):
9596
'nid': 'test',
9697
'provider': 'test',
9798
'action': 'upload',
99+
'intent': 'copyto',
98100
'path': '',
99101
'version': None,
100102
'metrics': {
@@ -109,24 +111,47 @@ async def test_permissions_post_copy_source_destination(self):
109111
class TestActionMapping:
110112

111113
@pytest.mark.asyncio
112-
@pytest.mark.parametrize('method, action, auth_type, headers, query_args, expected', [
113-
['post', 'copy', AuthType.SOURCE, None, None, 'download'],
114-
['post', 'copy', AuthType.DESTINATION, None, None, 'upload'],
115-
['post', 'move', AuthType.SOURCE, None, None, 'upload'], # TODO: really?
116-
['post', 'move', AuthType.DESTINATION, None, None, 'upload'],
117-
['post', 'rename', AuthType.SOURCE, None, None, 'upload'], # TODO: really?
118-
['post', 'rename', AuthType.DESTINATION, None, None, 'upload'],
119-
120-
['head', None, None, {settings.MFR_ACTION_HEADER: 'render'}, None, 'render'],
121-
['head', None, None, {settings.MFR_ACTION_HEADER: 'export'}, None, 'export'],
122-
123-
['get', None, None, None, None, 'download'],
124-
['put', None, None, None, None, 'upload'],
125-
['delete', None, None, None, None, 'delete'],
126-
['get', None, None, None, {'meta': 1}, 'metadata'],
127-
['get', None, None, None, {'revisions': 1}, 'revisions'],
114+
@pytest.mark.parametrize('method, action, path, auth_type, headers, query_args, exp_perm_action, exp_intent', [
115+
['post', 'copy', '/folder/', AuthType.SOURCE, None, None, 'download', 'copyfrom'],
116+
['post', 'copy', '/folder/', AuthType.DESTINATION, None, None, 'upload', 'copyto'],
117+
['post', 'move', '/folder/', AuthType.SOURCE, None, None, 'delete', 'movefrom'],
118+
['post', 'move', '/folder/', AuthType.DESTINATION, None, None, 'upload', 'moveto'],
119+
['post', 'rename', '/folder/', AuthType.SOURCE, None, None, 'upload', 'rename'],
120+
['post', 'rename', '/folder/', AuthType.DESTINATION, None, None, 'upload', 'rename'],
121+
122+
['post', 'copy', '/file', AuthType.SOURCE, None, None, 'download', 'copyfrom'],
123+
['post', 'copy', '/file', AuthType.DESTINATION, None, None, 'upload', 'copyto'],
124+
['post', 'move', '/file', AuthType.SOURCE, None, None, 'delete', 'movefrom'],
125+
['post', 'move', '/file', AuthType.DESTINATION, None, None, 'upload', 'moveto'],
126+
['post', 'rename', '/file', AuthType.SOURCE, None, None, 'upload', 'rename'],
127+
['post', 'rename', '/file', AuthType.DESTINATION, None, None, 'upload', 'rename'],
128+
129+
['head', None, '/folder/', None, {settings.MFR_ACTION_HEADER: 'render'}, None, 'render', 'render'],
130+
['head', None, '/folder/', None, {settings.MFR_ACTION_HEADER: 'export'}, None, 'export', 'export'],
131+
132+
['head', None, '/file', None, {settings.MFR_ACTION_HEADER: 'render'}, None, 'render', 'render'],
133+
['head', None, '/file', None, {settings.MFR_ACTION_HEADER: 'export'}, None, 'export', 'export'],
134+
135+
['get', None, '/folder/', None, None, None, 'metadata', 'metadata'],
136+
['head', None, '/folder/', None, None, None, 'metadata', 'metadata'],
137+
['put', None, '/folder/', None, None, {'kind': 'folder'}, 'upload', 'create_dir'],
138+
['put', None, '/folder/', None, None, {'kind': 'file'}, 'upload', 'create_file'],
139+
['delete', None, '/folder/', None, None, None, 'delete', 'delete'],
140+
['get', None, '/folder/', None, None, {'meta': 1}, 'metadata', 'metadata'],
141+
['get', None, '/folder/', None, None, {'revisions': 1}, 'metadata', 'metadata'],
142+
['get', None, '/folder/', None, None, {'zip': 1}, 'download', 'daz'],
143+
144+
['get', None, '/file', None, None, None, 'download', 'download'],
145+
['head', None, '/file', None, None, None, 'metadata', 'metadata'],
146+
['put', None, '/file', None, None, None, 'upload', 'update_file'],
147+
['delete', None, '/file', None, None, None, 'delete', 'delete'],
148+
['get', None, '/file', None, None, {'meta': 1}, 'metadata', 'metadata'],
149+
['get', None, '/file', None, None, {'revisions': 1}, 'revisions', 'revisions'],
150+
['get', None, '/file', None, None, {'zip': 1}, 'download', 'download'],
151+
['get', None, '/file', None, None, {'meta': 1, 'revisions': 1}, 'metadata', 'metadata'],
128152
])
129-
async def test_action_type(self, method, action, auth_type, headers, query_args, expected):
153+
async def test_action_type(self, method, action, path, auth_type, headers, query_args,
154+
exp_perm_action, exp_intent):
130155

131156
handler = OsfAuthHandler()
132157

@@ -136,7 +161,7 @@ async def test_action_type(self, method, action, auth_type, headers, query_args,
136161
request.query_arguments = query_args if query_args is not None else {}
137162
request.cookies = {}
138163

139-
kwargs = {}
164+
kwargs = {'path': path}
140165
if action is not None:
141166
kwargs['action'] = action
142167
if auth_type is not None:
@@ -151,7 +176,8 @@ async def test_action_type(self, method, action, auth_type, headers, query_args,
151176

152177
handler.build_payload.asssert_called_once()
153178
args, _ = handler.build_payload.call_args
154-
assert args[0]['action'] == expected
179+
assert args[0]['action'] == exp_perm_action
180+
assert args[0]['intent'] == exp_intent
155181

156182
@pytest.mark.asyncio
157183
async def test_unhandled_mfr_action(self):

waterbutler/auth/osf/handler.py

+100-31
Original file line numberDiff line numberDiff line change
@@ -102,37 +102,14 @@ async def fetch(self, request, bundle):
102102

103103
async def get(self, resource, provider, request, action=None, auth_type=AuthType.SOURCE,
104104
path='', version=None):
105-
"""Used for v1"""
106-
method = request.method.lower()
105+
"""Used for v1. Requests credentials and configuration from the OSF for the given resource
106+
(project) and provider. Auth credentials sent by the user to WB are passed onto the OSF so
107+
it can determine the user. Auth payload also includes some metrics and metadata to help
108+
the OSF with resource tallies and permission optimizations.
109+
"""
107110

108-
if method == 'post' and action:
109-
post_action_map = {
110-
'copy': 'download' if auth_type is AuthType.SOURCE else 'upload',
111-
'rename': 'upload',
112-
'move': 'upload',
113-
}
114-
115-
try:
116-
osf_action = post_action_map[action.lower()]
117-
except KeyError:
118-
raise exceptions.UnsupportedActionError(method, supported=post_action_map.keys())
119-
elif method == 'head' and settings.MFR_ACTION_HEADER in request.headers:
120-
mfr_action_map = {'render': 'render', 'export': 'export'}
121-
mfr_action = request.headers[settings.MFR_ACTION_HEADER].lower()
122-
try:
123-
osf_action = mfr_action_map[mfr_action]
124-
except KeyError:
125-
raise exceptions.UnsupportedActionError(mfr_action, supported=mfr_action_map.keys())
126-
else:
127-
try:
128-
if method == 'get' and 'meta' in request.query_arguments:
129-
osf_action = 'metadata'
130-
elif method == 'get' and 'revisions' in request.query_arguments:
131-
osf_action = 'revisions'
132-
else:
133-
osf_action = self.ACTION_MAP[method]
134-
except KeyError:
135-
raise exceptions.UnsupportedHTTPMethodError(method, supported=self.ACTION_MAP.keys())
111+
(permissions_req, intent) = self._determine_actions(resource, provider, request, action,
112+
auth_type, path, version)
136113

137114
headers = {'Content-Type': 'application/json'}
138115

@@ -155,7 +132,8 @@ async def get(self, resource, provider, request, action=None, auth_type=AuthType
155132
self.build_payload({
156133
'nid': resource,
157134
'provider': provider,
158-
'action': osf_action,
135+
'action': permissions_req, # what permissions does the user need?
136+
'intent': intent, # what is the user trying to do?
159137
'path': path,
160138
'version': version,
161139
'metrics': {
@@ -171,3 +149,94 @@ async def get(self, resource, provider, request, action=None, auth_type=AuthType
171149

172150
payload['auth']['callback_url'] = payload['callback_url']
173151
return payload
152+
153+
def _determine_actions(self, resource, provider, request, action=None,
154+
auth_type=AuthType.SOURCE, path='', version=None):
155+
"""Decide what the user is trying to achieve and what permissions they need to achieve it.
156+
Returns two values, ``osf_action`` and ``intended_action``. ``intended_action`` is a tag
157+
that describes what the user is trying to accomplish. This tag can have many values,
158+
currently including:
159+
160+
``copyfrom``, ``copyto``, ``create_dir``, ``create_file``, ``daz``, ``delete``,
161+
``download``, ``export``, ``metadata``, ``movefrom``, ``moveto``, ``rename``, ``render``,
162+
``revisions``, ``update_file``
163+
164+
``osf_action`` describes the permissions needed, but ofuscated with some legacy cruft. In
165+
theory, it only needs to be one of two values, ``readonly`` or `readwrite``. However, it
166+
used to be overloaded to convey intent in addition to permissions. For back-compat reasons,
167+
we are not changing the current returned value. Its possible values are a subset of the
168+
``intended_actions`` from which the OSF can infer whether ``ro`` or ``rw`` permissions are
169+
needed. The current mapping is:
170+
171+
Implies ``readonly``: ``metadata``, ``download``, ``render``, ``export``.
172+
173+
Implies ``readwrite``: ``upload``, ``delete``.
174+
"""
175+
176+
method = request.method.lower()
177+
osf_action, intended_action = None, None
178+
179+
if method == 'post' and action:
180+
post_action_map = {
181+
'copy': 'download' if auth_type is AuthType.SOURCE else 'upload',
182+
'move': 'delete' if auth_type is AuthType.SOURCE else 'upload',
183+
'rename': 'upload',
184+
}
185+
186+
intended_post_action_map = {
187+
'copy': 'copyfrom' if auth_type is AuthType.SOURCE else 'copyto',
188+
'move': 'movefrom' if auth_type is AuthType.SOURCE else 'moveto',
189+
'rename': 'rename',
190+
}
191+
192+
try:
193+
osf_action = post_action_map[action.lower()]
194+
intended_action = intended_post_action_map[action.lower()]
195+
except KeyError:
196+
raise exceptions.UnsupportedActionError(method, supported=post_action_map.keys())
197+
elif method == 'put':
198+
osf_action = 'upload'
199+
if not path.endswith('/'):
200+
intended_action = 'update_file'
201+
elif request.query_arguments.get('kind', '') == 'folder':
202+
intended_action = 'create_dir'
203+
else:
204+
intended_action = 'create_file'
205+
elif method == 'head' and settings.MFR_ACTION_HEADER in request.headers:
206+
mfr_action_map = {'render': 'render', 'export': 'export'}
207+
mfr_action = request.headers[settings.MFR_ACTION_HEADER].lower()
208+
try:
209+
osf_action = mfr_action_map[mfr_action]
210+
intended_action = mfr_action
211+
except KeyError:
212+
raise exceptions.UnsupportedActionError(mfr_action, supported=mfr_action_map.keys())
213+
else:
214+
try:
215+
if method == 'get':
216+
if path.endswith('/'): # path isa folder
217+
# folders ignore 'revisions' query param
218+
if 'zip' in request.query_arguments:
219+
osf_action = 'download'
220+
intended_action = 'daz'
221+
else:
222+
osf_action = 'metadata'
223+
intended_action = 'metadata'
224+
else: # path isa file, not folder
225+
# files ignore 'zip' query param
226+
# precedence order is 'meta', 'zip', default
227+
if 'meta' in request.query_arguments:
228+
osf_action = 'metadata'
229+
intended_action = 'metadata'
230+
elif 'revisions' in request.query_arguments:
231+
osf_action = 'revisions'
232+
intended_action = 'revisions'
233+
else:
234+
osf_action = 'download'
235+
intended_action = 'download'
236+
else:
237+
osf_action = self.ACTION_MAP[method]
238+
intended_action = self.ACTION_MAP[method]
239+
except KeyError:
240+
raise exceptions.UnsupportedHTTPMethodError(method, supported=self.ACTION_MAP.keys())
241+
242+
return (osf_action, intended_action)

waterbutler/server/api/v1/provider/movecopy.py

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async def move_or_copy(self):
123123
self.json.get('provider', self.provider.NAME),
124124
self.request,
125125
action=auth_action,
126+
path=path,
126127
auth_type=AuthType.DESTINATION,
127128
)
128129
self.dest_provider = make_provider(

0 commit comments

Comments
 (0)