~rjarry/dlrepo

dlrepo: more fine-grained access rights v2 APPLIED

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
#967576 .build.yml success
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:
Next
Hi Robin,

Le lun. 10 avr. 2023 à 20:13, Robin Jarry <robin@jarry.cc> a écrit :
Next
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH dlrepo v2 1/3] job: forbid changing internal status if locked Export this patch

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

[PATCH dlrepo v2 2/3] views: use HTTP POST methods for admin operations Export this patch

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

[PATCH dlrepo v2 3/3] auth: define more fine-grained access rights Export this patch

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"}):
                    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**
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
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: