This series divises the "write" access right into "add", "delete" and "update" sub-rights. After a preliminary work in patch 1, patch 2 switches all administration operations to HTTP POST requests, laying the ground to the implemention of the new access rights in patch 3. Julien Floret (3): job: forbid changing internal status if locked views: use HTTP POST methods for admin operations auth: define more fine-grained access rights Changes in v2: Update man pages (patches 2 and 3). Ignore ACLs with no read access (patch 3). dlrepo-cli | 18 +++++-- dlrepo/fs/job.py | 2 + dlrepo/views/auth.py | 77 ++++++++++++++++++++++------- dlrepo/views/branch.py | 2 +- dlrepo/views/job.py | 33 +++++++++++-- dlrepo/views/tag.py | 2 +- dlrepo/views/util.py | 2 +- docs/dlrepo-acls.5.scdoc | 33 +++++++------ docs/dlrepo-api.7.scdoc | 97 ++++++++++++++++++++++--------------- tests/test_auth.py | 96 ++++++++++++++++++++++++++++++++++++ tests/test_auth/acls/group4 | 10 ++++ tests/test_auth/auth | 1 + tests/test_publish.py | 5 +- 13 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 tests/test_auth/acls/group4 -- 2.30.2
dlrepo/patches/.build.yml: SUCCESS in 2m11s [more fine-grained access rights][0] v2 from [Julien Floret][1] [0]: https://lists.sr.ht/~rjarry/dlrepo/patches/40143 [1]: mailto:julien.floret@6wind.com ✓ #967576 SUCCESS dlrepo/patches/.build.yml https://builds.sr.ht/~rjarry/job/967576
Hey Julien, This looks good. Julien Floret, Apr 03, 2023 at 17:13:
Hi Robin, Le lun. 10 avr. 2023 à 20:13, Robin Jarry <robin@jarry.cc> a écrit :
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~rjarry/dlrepo/patches/40143/mbox | git am -3Learn more about email & git
Now, jobs must be unlocked prior to changing their release status ("internal" or releasable). This will be useful in the next commits. Signed-off-by: Julien Floret <julien.floret@6wind.com> Acked-by: Thomas Faivre <thomas.faivre@6wind.com> --- dlrepo/fs/job.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dlrepo/fs/job.py b/dlrepo/fs/job.py index 5e92e6d9cffa..cc177ba15ffb 100644 --- a/dlrepo/fs/job.py +++ b/dlrepo/fs/job.py @@ -208,6 +208,8 @@ class Job(SubDir): return self._internal_path().is_file() def set_internal(self, internal: bool): + if self.is_locked(): + raise FileExistsError("Job is locked") path = self._internal_path() if internal: path.touch() -- 2.30.2
dlrepo uses the HTTP PUT method for creating new jobs (uploading artifacts and "finalizing" the newly created job). On the other hand, the HTTP PUT method is also used for so-called "administration" operations, which are: - releasing and unreleasing tags - marking tags stable or unstable - locking tags (to save them from automatic cleanup) - managing the branches' cleanup policy - modifying an existing job For these operations, let's use the HTTP POST method instead. Having a different method for administration tasks will help us define more fine-grained access rights in a next commit. There is small conceptual difference between the two methods; as explained in the link below, PUT is considered idempotent, whereas POST can cause a change of state or side effects in the server. Link: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods Signed-off-by: Julien Floret <julien.floret@6wind.com> Acked-by: Thomas Faivre <thomas.faivre@6wind.com> --- dlrepo-cli | 18 ++++++++++++++---- dlrepo/views/branch.py | 2 +- dlrepo/views/job.py | 33 ++++++++++++++++++++++++++++----- dlrepo/views/tag.py | 2 +- docs/dlrepo-api.7.scdoc | 31 ++++++++++++++++++++++++++----- tests/test_publish.py | 5 ++++- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/dlrepo-cli b/dlrepo-cli index 545269df93d9..a20145671544 100755 --- a/dlrepo-cli +++ b/dlrepo-cli @@ -282,10 +282,14 @@ def lock(args): if args.job: url = os.path.join("branches", args.branch, args.tag, args.job) + "/" data = {"job": data} + if args.unlock: + client.post(url, data) + else: + client.put(url, data) else: url = os.path.join("branches", args.branch, args.tag) + "/" data = {"tag": data} - client.put(url, data) + client.post(url, data) # -------------------------------------------------------------------------------------- @@ -433,7 +437,7 @@ def release(args): """ client = HttpClient(args.url) url = os.path.join("branches", args.branch, args.tag) + "/" - client.put(url, {"tag": {"released": not args.unset}}) + client.post(url, {"tag": {"released": not args.unset}}) # -------------------------------------------------------------------------------------- @@ -453,7 +457,7 @@ def stable(args): """ client = HttpClient(args.url) url = os.path.join("branches", args.branch, args.tag) + "/" - client.put(url, {"tag": {"stable": not args.unset}}) + client.post(url, {"tag": {"stable": not args.unset}}) # -------------------------------------------------------------------------------------- @@ -551,7 +555,7 @@ def cleanup_policy(args): if args.max_released is not None: policy["max_released_tags"] = args.max_released if policy: - client.put(os.path.join("branches", args.branch) + "/", {"branch": policy}) + client.post(os.path.join("branches", args.branch) + "/", {"branch": policy}) else: data = client.get(os.path.join("branches", args.branch) + "/") if args.raw_json: @@ -719,6 +723,12 @@ class HttpClient: headers["Content-Type"] = "text/plain" return body, headers + def post(self, url, body, headers=None): + url = self.make_url(url) + body, headers = self._encode_body(body, headers) + request = Request(url, body, method="POST") + return self._send(request, headers) + def put(self, url, body, headers=None): url = self.make_url(url) body, headers = self._encode_body(body, headers) diff --git a/dlrepo/views/branch.py b/dlrepo/views/branch.py index 720ba24437f4..5c2318369098 100644 --- a/dlrepo/views/branch.py +++ b/dlrepo/views/branch.py @@ -101,7 +101,7 @@ class BranchView(BaseView): return aiohttp_jinja2.render_template("branch.html", self.request, data) return web.json_response(data) - async def put(self): + async def post(self): """ Update the cleanup policy for a branch. """ diff --git a/dlrepo/views/job.py b/dlrepo/views/job.py index 2c6a13cd3d30..644cd3ae3576 100644 --- a/dlrepo/views/job.py +++ b/dlrepo/views/job.py @@ -93,16 +93,39 @@ class JobView(BaseView): if internal is not None and not isinstance(internal, bool): raise TypeError() locked = data.get("locked") - if locked is not None and not isinstance(locked, bool): - raise TypeError() - except (TypeError, KeyError) as e: + if locked is not None: + if not isinstance(locked, bool): + raise TypeError() + if not locked: + raise ValueError() + except (TypeError, KeyError, ValueError) as e: raise web.HTTPBadRequest(reason="Invalid parameters") from e try: if internal is not None: job.set_internal(internal) - if locked is not None: - await job.set_locked(locked) + if locked: + await job.set_locked(True) + except FileNotFoundError as e: + raise web.HTTPNotFound() from e + except OSError as e: + raise web.HTTPBadRequest(reason=str(e)) from e + + return web.Response() + + async def post(self): + job = _get_job(self.repo(), self.request) + try: + locked = (await self.json_body())["job"]["locked"] + if not isinstance(locked, bool): + raise TypeError() + if locked: + raise ValueError() + except (TypeError, KeyError, ValueError) as e: + raise web.HTTPBadRequest(reason="Invalid parameters") from e + + try: + await job.set_locked(False) except FileNotFoundError as e: raise web.HTTPNotFound() from e except OSError as e: diff --git a/dlrepo/views/tag.py b/dlrepo/views/tag.py index 5e75d28dc486..a2779093fb24 100644 --- a/dlrepo/views/tag.py +++ b/dlrepo/views/tag.py @@ -74,7 +74,7 @@ class TagView(BaseView): return aiohttp_jinja2.render_template("tag.html", self.request, data) return web.json_response(data) - async def put(self): + async def post(self): """ Change the released, stable and/or locked statuses of a tag. """ diff --git a/docs/dlrepo-api.7.scdoc b/docs/dlrepo-api.7.scdoc index 1d46bea6c8b4..aaa8cdfd36c0 100644 --- a/docs/dlrepo-api.7.scdoc +++ b/docs/dlrepo-api.7.scdoc @@ -168,8 +168,8 @@ specified in the query parameters, only released tags are returned. *Errors:* - _404_: the specified _{branch}_ does not exist. -## PUT /branches/{branch}/ -## PUT /~{user}/branches/{branch}/ +## POST /branches/{branch}/ +## POST /~{user}/branches/{branch}/ Change the cleanup policy of the specified _{branch}_. @@ -252,8 +252,8 @@ user has access. *Errors:* - _404_: the specified _{tag}_ does not exist. -## PUT /branches/{branch}/{tag}/ -## PUT /~{user}/branches/{branch}/{tag}/ +## POST /branches/{branch}/{tag}/ +## POST /~{user}/branches/{branch}/{tag}/ Change the released and/or locked status of a _{tag}_. @@ -351,7 +351,7 @@ the archive contents after extraction. ## PUT /branches/{branch}/{tag}/{job}/ ## PUT /~{user}/branches/{branch}/{tag}/{job}/ -Change the internal and/or locked status of a _{job}_. An internal job can never +Change the internal status of a _{job}_ or lock it. An internal job can never be published to another server. A locked job can no longer receive new artifact uploads nor metadata change. @@ -371,6 +371,27 @@ uploads nor metadata change. - _404_: the specified _{job}_ does not exist. - _405_: _{tag}_ is either _latest_, _stable_ or _oldstable_. +## POST /branches/{branch}/{tag}/{job}/ +## POST /~{user}/branches/{branch}/{tag}/{job}/ + +Unlock a _{job}_. An unlocked job can receive new artifact uploads or metadata +change, but cannot be released. + +*Access:* + _rw_ +*Request:* + ``` + { + "job": { + "locked": false + } + } + ``` +*Errors:* + - _400_: invalid parameters. + - _404_: the specified _{job}_ does not exist. + - _405_: _{tag}_ is either _latest_, _stable_ or _oldstable_. + ## PATCH /branches/{branch}/{tag}/{job}/ ## PATCH /~{user}/branches/{branch}/{tag}/{job}/ diff --git a/tests/test_publish.py b/tests/test_publish.py index 7c9f2f572749..c14fceeb3383 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -64,7 +64,10 @@ def dlrepo_servers(request, dlrepo_server): async def test_publish(dlrepo_servers): # pylint: disable=redefined-outer-name url, public_url = dlrepo_servers async with aiohttp.ClientSession(url) as sess: - resp = await sess.put("/branches/branch/tag/", json={"tag": {"released": True}}) + resp = await sess.post( + "/branches/branch/tag/", + json={"tag": {"released": True}}, + ) assert resp.status == 200 await asyncio.sleep(1) resp = await sess.get("/branches/branch/tag/") -- 2.30.2
Before this commit, only two different access rights were defined: "read" for HTTP GET and HEAD methods, and "write" for all other methods. Thus, the "write" access right allowed very different operations; adding and deleting artifacts, but also releasing tags or managing the tag cleanup policies... This commit splits the "write" access right into several sub-rights: "a" (for "add") covers the creation of new jobs: - uploading new artifacts, - removing the "dirty" flag on new formats, - setting a job's "internal" flag, - locking new jobs to prevent any further alterations. "add" operations are done via HTTP PUT and PATCH requests (and POST in case of docker registry: "/v2/*" URLs). "d" (for "delete") covers the deletion of branches, tags and jobs. "delete" operations are done via HTTP DELETE requests. "u" (for "update") covers administration operations: - unlocking existing jobs for modifications, - releasing and unreleasing tags, - marking tags stable or unstable, - locking tags (to save them from automatic cleanup), - managing the branches' cleanup policy. "update" operations are done via HTTP POST requests (except for docker commands, see above). While the "add" access right should typically be given to CI tools that automatically build and upload artifacts, the "delete" and "update" rights on the root repository should typically be reserved to a handful of administrators. Signed-off-by: Julien Floret <julien.floret@6wind.com> Acked-by: Thomas Faivre <thomas.faivre@6wind.com> --- dlrepo/views/auth.py | 77 ++++++++++++++++++++++------- dlrepo/views/util.py | 2 +- docs/dlrepo-acls.5.scdoc | 33 +++++++------ docs/dlrepo-api.7.scdoc | 68 +++++++++++++------------- tests/test_auth.py | 96 +++++++++++++++++++++++++++++++++++++ tests/test_auth/acls/group4 | 10 ++++ tests/test_auth/auth | 1 + 7 files changed, 219 insertions(+), 68 deletions(-) create mode 100644 tests/test_auth/acls/group4 diff --git a/dlrepo/views/auth.py b/dlrepo/views/auth.py index e070c52f28ee..ea68d0b7fbf9 100644 --- a/dlrepo/views/auth.py +++ b/dlrepo/views/auth.py @@ -187,8 +187,12 @@ class AuthBackend: if self.AUTH_DISABLED: return - is_write = request.method not in ("GET", "HEAD") - if not is_write: + is_add = request.method in ("PUT", "PATCH") or ( + request.method == "POST" and request.path.startswith("/v2/") + ) + is_delete = request.method == "DELETE" + is_update = request.method == "POST" and not request.path.startswith("/v2/") + if not is_add and not is_delete and not is_update: if request.path.startswith("/static/") or request.path == "/favicon.ico": # no need for authentication for these return @@ -206,7 +210,9 @@ class AuthBackend: if login is None: groups = frozenset({"ANONYMOUS"}) - if login is None and (is_write or request.path == "/v2/"): + if login is None and ( + is_add or is_delete or is_update or request.path == "/v2/" + ): # anonymous write access is always denied # the docker registry API states that anonymous GET requests to /v2/ # must receive a 401 unauthorized error @@ -224,27 +230,29 @@ class AuthBackend: request["dlrepo_user_acls"] = acls if not self.access_granted( - acls, is_write, request.path, request["dlrepo_user"] + acls, is_add, is_delete, is_update, request.path, request["dlrepo_user"] ): raise self.auth_error(request) def access_granted( self, acls: FrozenSet["ACL"], - is_write: bool, + is_add: bool, + is_delete: bool, + is_update: bool, url: str, username: Optional[str] = None, ) -> bool: """ Check if the provided ACLs give write and/or read access to the specified URL. """ - access_key = (acls, is_write, url) + access_key = (acls, is_add, is_delete, is_update, url) granted = self.access_cache.get(access_key) if granted is not None: return granted granted = False for acl in acls: - if acl.access_granted(is_write, url): + if acl.access_granted(is_add, is_delete, is_update, url): if username is not None: LOG.debug("access granted to %s by ACL %s", username, acl) granted = True @@ -317,15 +325,26 @@ def parse_group_acls() -> Dict[str, FrozenSet["ACL"]]: if len(tokens) < 2: continue access, *args = tokens - if access not in ("ro", "rw"): + + # must be at least readable + if "r" not in access: continue + + # "w" is equivalent to "adu" + # "o" is ignored for compatibility with "ro" + if not set(access).issubset({"r", "o", "w", "a", "d", "u"}):
Note for later, you can shorten this to: if not set(access).issubset("rowadu"): No need for a reroll.
+ continue + + add = "a" in access or "w" in access + delete = "d" in access or "w" in access + update = "u" in access or "w" in access globs = [] for a in args: if a.startswith("!"): globs.append((a[1:], True)) else: globs.append((a, False)) - acls.add(ACL(access == "rw", frozenset(globs))) + acls.add(ACL(add, delete, update, frozenset(globs))) group_acls[file.name] = frozenset(acls) except FileNotFoundError as e: @@ -337,8 +356,12 @@ def parse_group_acls() -> Dict[str, FrozenSet["ACL"]]: # -------------------------------------------------------------------------------------- class ACL: - def __init__(self, read_write: bool, globs: FrozenSet[Tuple[str, bool]]): - self.read_write = read_write + def __init__( + self, add: bool, delete: bool, update: bool, globs: FrozenSet[Tuple[str, bool]] + ): + self.add = add + self.delete = delete + self.update = update self.globs = globs self.can_expand_user = any("$user" in g for g, _ in self.globs) self.patterns = [ @@ -351,10 +374,16 @@ class ACL: globs = [] for glob, invert in self.globs: globs.append((glob.replace("$user", login), invert)) - return ACL(self.read_write, frozenset(globs)) + return ACL(self.add, self.delete, self.update, frozenset(globs)) - def access_granted(self, is_write: bool, path: str) -> bool: - if is_write and not self.read_write: + def access_granted( + self, is_add: bool, is_delete: bool, is_update: bool, path: str + ) -> bool: + if is_add and not self.add: + return False + if is_delete and not self.delete: + return False + if is_update and not self.update: return False granted = True for pattern, invert in self.patterns: @@ -391,7 +420,14 @@ class ACL: return re.compile(pattern) def __str__(self): - access = "rw" if self.read_write else "ro" + access = "r" + if self.add: + access += "a" + if self.delete: + access += "d" + if self.update: + access += "u" + globs = [] for glob, invert in self.globs: if invert: @@ -400,12 +436,17 @@ class ACL: return f"{access} {' '.join(globs)}" def __repr__(self): - return f"ACL(read_write={self.read_write}, globs={self.globs})" + return f"ACL(add={self.add}, delete={self.delete}, update={self.update}, globs={self.globs})" def __eq__(self, other): if not isinstance(other, ACL): return False - return other.read_write == self.read_write and other.globs == self.globs + return ( + other.add == self.add + and other.delete == self.delete + and other.update == self.update + and other.globs == self.globs + ) def __hash__(self): - return hash((self.read_write, self.globs)) + return hash((self.add, self.delete, self.update, self.globs)) diff --git a/dlrepo/views/util.py b/dlrepo/views/util.py index 09d7521214b4..c231b0272473 100644 --- a/dlrepo/views/util.py +++ b/dlrepo/views/util.py @@ -34,7 +34,7 @@ class BaseView(web.View): return True auth_backend = self.request.app[auth.AuthBackend.KEY] acls = self.request["dlrepo_user_acls"] - return auth_backend.access_granted(acls, False, url) + return auth_backend.access_granted(acls, False, False, False, url) X_SENDFILE_HEADER = os.getenv("DLREPO_X_SENDFILE_HEADER", None) diff --git a/docs/dlrepo-acls.5.scdoc b/docs/dlrepo-acls.5.scdoc index 2a3782768c37..80edaaf8a42f 100644 --- a/docs/dlrepo-acls.5.scdoc +++ b/docs/dlrepo-acls.5.scdoc @@ -30,9 +30,12 @@ users). By default, no one can access anything outside of the _/static/\*\*_ and _/favicon.ico_ URLs. -An ACL file must contain lines that begin with _ro_ for read-only access or -_rw_ for read-write access, followed by one or more spaces/tabs and a pattern. -The pattern should match URLs that the group has access to. +An ACL file must contain lines that begin with an access string. The access +must contain at least _r_ for read access, and optionally _w_ for write access; +_w_ can also be split into _a_, _d_ and/or _u_ access (add/delete/update). +An ACL without read access will be ignored. +The access must be followed by one or more spaces/tabs and a pattern. The +pattern should match URLs that the group has access to. It accepts very basic shell-like wild cards: @@ -66,7 +69,7 @@ An ACL line gives access *only* to the specified pattern. For example, the following ACL: ``` -ro /products/foobar/x86/3.5/** +r /products/foobar/x86/3.5/** ``` will give access *only* to _/products/foobar/x86/3.5/_ and all its sub folders. @@ -74,10 +77,10 @@ The parent URLs will *not* be accessible. These other URLs must be explicitly allowed in separate ACL lines: ``` -ro / -ro /products/ -ro /products/foobar/ -ro /products/foobar/x86/ +r / +r /products/ +r /products/foobar/ +r /products/foobar/x86/ ``` # EXAMPLES @@ -85,13 +88,13 @@ ro /products/foobar/x86/ Read-only access to everything: ``` -ro /** +r /** ``` -Read-write access to a specific branch: +Read & add access to a specific branch: ``` -rw /branches/2.x/** +ra /branches/2.x/** ``` Read-write access per user: @@ -103,20 +106,20 @@ rw /~$user/branches/** Read-only access for a specific product variant: ``` -ro /products/foobaz/x86_64/** +r /products/foobaz/x86_64/** ``` Read-only access with an exclusion pattern: ``` -ro /products/moo/ppc64el/1.0/** !/products/moo/ppc64el/1.0/*/debuginfo** +r /products/moo/ppc64el/1.0/** !/products/moo/ppc64el/1.0/*/debuginfo** ``` Read-only access to container images (via *docker pull*): ``` -ro /v2/ -ro /v2/foomoo/arm64/3.0/** +r /v2/ +r /v2/foomoo/arm64/3.0/** ``` # SEE ALSO diff --git a/docs/dlrepo-api.7.scdoc b/docs/dlrepo-api.7.scdoc index aaa8cdfd36c0..df230da36b72 100644 --- a/docs/dlrepo-api.7.scdoc +++ b/docs/dlrepo-api.7.scdoc @@ -25,8 +25,8 @@ Description of the endpoint. The URL segments in _{braces}_ are variable and depend on files/folders present in the repository. *Access:* - Required ACLs to access this endpoint. _ro_ for read-only, _rw_ for - read-write. + Required ACLs to access this endpoint. _r_ for read, _a_ for add, + _d_ for delete, _u_ for update. *Request:* Description of the required arguments in the request body in JSON if any. @@ -50,7 +50,7 @@ All endpoints may return these common errors: Show the home page of the base repository. *Access:* - _ro_ + _r_ *Response:* _text/html_ @@ -59,7 +59,7 @@ Show the home page of the base repository. Show the home page of the specified _{user}_ repository. *Access:* - _ro_ + _r_ *Response:* _text/html_ *Errors:* @@ -83,7 +83,7 @@ Get the client script source code. If *DLREPO_PUBLIC_URL* is set, the default URL is replaced from _http://127.0.0.1:1337_. *Access:* - _ro_ + _r_ *Response:* The contents of the file. @@ -96,7 +96,7 @@ Get the list of branches present in the base repository or in the specified _{user}_ repository. *Access:* - _ro_ + _r_ Only the branches that the user has access to are returned. *Response:* @@ -131,7 +131,7 @@ Get the list of tags present in the specified _{branch}_. If _released_ is specified in the query parameters, only released tags are returned. *Access:* - _ro_ + _r_ Only the tags that the user has access to are returned. *Response:* @@ -174,7 +174,7 @@ specified in the query parameters, only released tags are returned. Change the cleanup policy of the specified _{branch}_. *Access:* - _rw_ + _u_ *Request:* ``` { @@ -195,7 +195,7 @@ Change the cleanup policy of the specified _{branch}_. Delete the specified _{branch}_ and all its tags. *Access:* - _rw_ + _d_ *Errors:* - _400_: if one of the tags is released or locked and _force_ was not specified in the query parameters. @@ -216,7 +216,7 @@ _oldstable_: the most recent but one _stable_ tag in _{branch}_ to which the user has access. *Access:* - _ro_ + _r_ Only the jobs that the user has access to are returned. *Response:* @@ -258,7 +258,7 @@ user has access. Change the released and/or locked status of a _{tag}_. *Access:* - _rw_ + _u_ *Request:* ``` { @@ -287,7 +287,7 @@ The publication status can be fetched with a *GET* request on the same tag. Delete the specified _{tag}_ and all its jobs. *Access:* - _rw_ + _d_ *Errors:* - _400_: the tag is locked or it is released and _force_ was not specified in the query parameters. @@ -302,7 +302,7 @@ Delete the specified _{tag}_ and all its jobs. Get metadata and artifact formats for the specified _{job}_. *Access:* - _ro_ + _r_ Only the artifact formats that the user has access to are returned. *Response:* @@ -339,7 +339,7 @@ _{job}_. An extra _SHA256SUMS.txt_ file is appended to allow verification of the archive contents after extraction. *Access:* - _ro_ + _r_ Only files from the artifact formats that the user has access to are returned. @@ -356,7 +356,7 @@ be published to another server. A locked job can no longer receive new artifact uploads nor metadata change. *Access:* - _rw_ + _a_ *Request:* ``` { @@ -378,7 +378,7 @@ Unlock a _{job}_. An unlocked job can receive new artifact uploads or metadata change, but cannot be released. *Access:* - _rw_ + _u_ *Request:* ``` { @@ -398,7 +398,7 @@ change, but cannot be released. Set metadata of the specified _{job}_. *Access:* - _rw_ + _a_ *Request:* ``` { @@ -425,7 +425,7 @@ Set metadata of the specified _{job}_. Delete the specified _{job}_ and all its artifact formats. *Access:* - _rw_ + _d_ *Errors:* - _404_: the specified _{job}_ does not exist. - _405_: _{tag}_ is either _latest_, _stable_ or _oldstable_. @@ -439,7 +439,7 @@ Get the list of products present in the base repository or in the specified _{user}_ repository. *Access:* - _ro_ + _r_ Only the products that the user has access to are returned. *Response:* @@ -462,7 +462,7 @@ _{user}_ repository. Get the variants of the specified _{product}_. *Access:* - _ro_ + _r_ Only product variants that the user has access to are returned. *Response:* @@ -490,7 +490,7 @@ Get the variants of the specified _{product}_. Get the branches of the specified product _{variant}_. *Access:* - _ro_ + _r_ Only product branches that the user has access to are returned. *Response:* @@ -518,7 +518,7 @@ Get the branches of the specified product _{variant}_. Get the versions of the specified _{product_branch}_. *Access:* - _ro_ + _r_ Only product versions that the user has access to are returned. *Response:* @@ -564,7 +564,7 @@ _oldstable_: the most recent but one _stable_ version in _{product_branch}_ to which the user has access. *Access:* - _ro_ + _r_ Only the artifact formats that the user has access to are returned. *Response:* @@ -596,7 +596,7 @@ _{version}_. An extra _SHA256SUMS.txt_ file is appended to allow verification of the archive contents after extraction. *Access:* - _ro_ + _r_ Only files from the artifact formats that the user has access to are returned. @@ -615,7 +615,7 @@ of the archive contents after extraction. Get the list of files present in the specified _{format}_. *Access:* - _ro_ + _r_ *Response:* A _text/html_ page if the _Accept_ HTTP header contains _text/html_. Otherwise a JSON response: @@ -645,7 +645,7 @@ If there is only one file in _{format}_, redirect to its URL. Otherwise, redirect to _{format}/_. *Access:* - _ro_ + _r_ *Response:* A _302_ redirect response. *Errors:* @@ -661,7 +661,7 @@ extra _SHA256SUMS.txt_ file is appended to allow verification of the archive contents after extraction. *Access:* - _ro_ + _r_ *Response:* A POSIX tar archive (_application/x-tar_). *Errors:* @@ -675,7 +675,7 @@ contents after extraction. Get the SHA-256 digests of files in the specified artifact _{format}_. *Access:* - _ro_ + _r_ *Response:* A plain text response with one line per file consisting of the SHA-256 digest of each file (64 hexadecimal characters) followed by two spaces @@ -695,7 +695,7 @@ Get the SHA-256 digests of files in the specified artifact _{format}_. Check if the specified _{format}_ exists and is not _dirty_. *Access:* - _ro_ + _r_ *Errors:* - _404_: the specified _{format}_ does not exist or has the _dirty_ flag set to _true_. @@ -717,7 +717,7 @@ refreshed since they may have been modified by the post process command. The _dirty_ flag will only be cleared if the command succeeds. *Access:* - _rw_ + _a_ *Errors:* - _404_: the specified _{tag}_ or _{version}_ does not exist. - _405_: _{tag}_ or _{version}_ is either _latest_, _stable_ or _oldstable_. @@ -733,7 +733,7 @@ _dirty_ flag will only be cleared if the command succeeds. Get the contents of the specified _{artifact}_ file. *Access:* - _ro_ + _r_ *Response:* Content-Type: application/octet-stream++ Content-Length: 46676++ @@ -752,7 +752,7 @@ Digest: sha256:5387a5f82442013ed24e0e3674fac3bdea9d2f85c88c9e5938ad6792f81d7799 Get the SHA-256 digest of the specified _{artifact}_ file. *Access:* - _ro_ + _r_ *Response:* The SHA-256 digest of the file (64 hexadecimal characters) followed by two spaces and the file name. @@ -773,7 +773,7 @@ ignored and a success response is returned if a blob with the specified digest already exists on the server. *Access:* - _ro_ + _r_ *Response:* Content-Type: application/octet-stream++ Content-Length: 46676++ @@ -792,7 +792,7 @@ set to the same value than _Digest_, _Content-Length_ may be set to _0_ and the file content may be omitted. *Access:* - _rw_ + _a_ *Request:* Content-Type: application/octet-stream++ Content-Length: 46676++ diff --git a/tests/test_auth.py b/tests/test_auth.py index 306e042fb4f2..039ef9f96a4d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -114,3 +114,99 @@ async def test_acl_regexp(dlrepo_server): assert resp.status == 200 data = await resp.read() assert data == b"content" + + +async def _test_acl_combination(dlrepo_server, tag, add, delete, update): + tag = f"/branches/main/{tag}" + url, _ = dlrepo_server + data = b"content" + digest = hashlib.sha256(data).hexdigest() + async with aiohttp.ClientSession(url, auth=aiohttp.BasicAuth("bar", "bar")) as sess: + resp = await sess.put( + f"{tag}/job/format/file.txt", + data=data, + headers={"Digest": f"sha256:{digest}"}, + ) + assert resp.status == 201 + async with aiohttp.ClientSession( + url, auth=aiohttp.BasicAuth("plop", "plop") + ) as sess: + resp = await sess.put( + f"{tag}/job/format/file.txt", + data=data, + headers={"Digest": f"sha256:{digest}"}, + ) + if add: + assert resp.status == 201 + else: + assert resp.status == 401 + resp = await sess.post(tag, json={"tag": {"stable": True}}) + if update: + assert resp.status == 200 + else: + assert resp.status == 401 + resp = await sess.delete(f"{tag}/job") + if delete: + assert resp.status == 200 + else: + assert resp.status == 401 + + +async def test_r(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_r_tag", add=False, delete=False, update=False + ) + + +async def test_ra(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_ra_tag", add=True, delete=False, update=False + ) + + +async def test_rd(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_rd_tag", add=False, delete=True, update=False + ) + + +async def test_ru(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_ru_tag", add=False, delete=False, update=True + ) + + +async def test_rad(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_rad_tag", add=True, delete=True, update=False + ) + + +async def test_rau(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_rau_tag", add=True, delete=False, update=True + ) + + +async def test_rdu(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_rdu_tag", add=False, delete=True, update=True + ) + + +async def test_radu(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_radu_tag", add=True, delete=True, update=True + ) + + +async def test_rw(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_rw_tag", add=True, delete=True, update=True + ) + + +async def test_a(dlrepo_server): + await _test_acl_combination( + dlrepo_server, "test_a_tag", add=False, delete=False, update=False + ) diff --git a/tests/test_auth/acls/group4 b/tests/test_auth/acls/group4 new file mode 100644 index 000000000000..6fe7bd29f156 --- /dev/null +++ b/tests/test_auth/acls/group4 @@ -0,0 +1,10 @@ +r /branches/main/test_r_tag** +ra /branches/main/test_ra_tag** +rd /branches/main/test_rd_tag** +ru /branches/main/test_ru_tag** +rad /branches/main/test_rad_tag** +rau /branches/main/test_rau_tag** +rdu /branches/main/test_rdu_tag** +radu /branches/main/test_radu_tag** +rw /branches/main/test_rw_tag** +a /branches/main/test_a_tag**
I assume this is to validate that an acl line without "r" is ignored?
diff --git a/tests/test_auth/auth b/tests/test_auth/auth index 7ddc01e77bca..ae3212d1be67 100644 --- a/tests/test_auth/auth +++ b/tests/test_auth/auth @@ -1,3 +1,4 @@ foo:baz:group1 bar:bar:group2 coin:coin:group3 +plop:plop:group4 -- 2.30.2
builds.sr.ht <builds@sr.ht>dlrepo/patches/.build.yml: SUCCESS in 2m11s [more fine-grained access rights][0] v2 from [Julien Floret][1] [0]: https://lists.sr.ht/~rjarry/dlrepo/patches/40143 [1]: mailto:julien.floret@6wind.com ✓ #967576 SUCCESS dlrepo/patches/.build.yml https://builds.sr.ht/~rjarry/job/967576
Hey Julien, This looks good. Julien Floret, Apr 03, 2023 at 17:13: