Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cloudfile_ext/v2/ACL_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo_id>/dir/?p=...&start=0&limit=200[&if_dir_id=<dir object id>]`
Expand Down
10 changes: 9 additions & 1 deletion cloudfile_ext/v2/acl_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 25 additions & 0 deletions cloudfile_ext/v2/tests/test_acl_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading