diff --git a/cloudfile_ext/v2/ACL_CONTRACT.md b/cloudfile_ext/v2/ACL_CONTRACT.md index 80db656bf8..7324be088b 100644 --- a/cloudfile_ext/v2/ACL_CONTRACT.md +++ b/cloudfile_ext/v2/ACL_CONTRACT.md @@ -118,6 +118,24 @@ check. Repo API tokens cannot impersonate current users on protected repos. Whether a repo is protected is answered from the same library rule snapshot (a virtual repo also checks its origin; an over-budget library is protected). +### Native write entries consume the same share root + +The exception above is not only a Hub-side shape. Native upload links, mkdir, +rename, move, delete and seafdav all gate on C's +`seafile_check_permission_by_path`, whose native library answer for a +non-member is "none". C therefore asks this same authority, once, at the exact +path, when -- and only when -- the library gate denied (an RPC *failure* is +propagated, never retried as a share question). The answer is `r`/`rw` for that +path; the library root and every unshared path stay denied, `admin` is never +producible, and the Go/C final publication checks are unchanged. + +`/cloudfile/acl/check/` carries the level in `permissions`, aligned with +`checks`, because a second `action=write` probe would double the round trip on +every share-root write. A caller that ignores the field, or an older Hub that +does not send it, keeps the *deny* reading: not "assume rw". The seam is +registered only where the V2 managed schema exists and an internal Hub channel +is configured; elsewhere a stock CE denial is byte-identical to before. + ### Paged CE directory listing `GET api/v2.1/repos//dir/?p=...&start=0&limit=200[&if_dir_id=]` diff --git a/cloudfile_ext/v2/acl_http.py b/cloudfile_ext/v2/acl_http.py index 181f189048..0be51d3e24 100644 --- a/cloudfile_ext/v2/acl_http.py +++ b/cloudfile_ext/v2/acl_http.py @@ -206,13 +206,21 @@ def post(self, request): if not isinstance(checks, list) or not 1 <= len(checks) <= 4096: raise ValueError() authority = Authority(data['username']) + permissions = [] for check in checks: if set(check) != {'path', 'kind', 'action'} or check['action'] not in ('read', 'write'): raise ValueError() decision = authority.effective(data['repo_id'], check['path'], check['kind']) if not (decision.can_read if check['action'] == 'read' else decision.can_write): raise PermissionError() - return response({'allowed': True, 'contract': 'cf-acl-v1'}) + permissions.append(decision.permission) + # `permissions` is aligned with `checks` and carries the V2 level, + # not just the batch verdict. Native C asks here for a single path + # when it has no library grant at all, and it needs to know whether + # the qualified share root is r or rw: probing again with + # action=write would double the round trip for every write. + return response({'allowed': True, 'contract': 'cf-acl-v1', + 'permissions': permissions}) except Exception as error: return failure(error) diff --git a/cloudfile_ext/v2/tests/test_acl_http.py b/cloudfile_ext/v2/tests/test_acl_http.py index 0aae51bc0d..9b0beb6210 100644 --- a/cloudfile_ext/v2/tests/test_acl_http.py +++ b/cloudfile_ext/v2/tests/test_acl_http.py @@ -200,6 +200,31 @@ def test_native_proof_must_be_internal_short_lived_and_current(self): authority.return_value.effective.return_value = Decision('invisible','/shared',True) self.assertEqual(self.native(good).status_code,403) + def test_native_answer_carries_the_level_not_only_the_verdict(self): + # Native C has no library grant at a share root, so it needs the V2 + # level (r vs rw) out of this one call: probing again with + # action=write would double the round trip on every upload. + claims = dict(is_internal=True, exp=int(time.time()) + 60) + checks = [dict(path='/shared', kind='dir', action='read'), + dict(path='/shared/file', kind='file', action='write')] + with patch('cloudfile_ext.v2.acl_http.Authority') as authority: + # One read-only directory grant and one writable file grant: + # the read check may return r, while a write check must be rw. + authority.return_value.effective.side_effect = [Decision('r', '/shared', True), + Decision('rw', '/shared', True)] + result = self.native(claims, dict(username='native', repo_id=RID, checks=checks)) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data['permissions'], ['r', 'rw']) + self.assertTrue(result.data['allowed']) + self.assertEqual(result.data['contract'], 'cf-acl-v1') + # Denial still answers 403 and never leaks a partial level list. + authority.return_value.effective.side_effect = None + authority.return_value.effective.return_value = Decision('r', '/shared', True) + denied = self.native(claims, dict(username='native', repo_id=RID, checks=[ + dict(path='/shared/file', kind='file', action='write')])) + self.assertEqual(denied.status_code, 403) + self.assertNotIn('permissions', denied.data) + def test_native_missing_subject_adapter_and_unknown_checks_fail_closed(self): with patch('cloudfile_ext.v2.acl_http.Authority',side_effect=ACLUnavailable()): self.assertEqual(self.native(dict(is_internal=True,exp=int(time.time())+60)).status_code,503)