Repository: conan-io/conan

Issue:
[bug] conan_server authorization oddity
I noticed a quite unexpected and potentially dangerous oddity with the way `conan_server` handles authorization.

When a package without user and channel is uploaded like "MyPackage/1.0@" the reference gets stored with user=None.
What is odd is that `conan_server` does not check any permission-rules when the user requesting data equals the user of the ref.
This means that even if the permission rules explicitly have a rule like
```
[read_permissions]
*/*@*/*: some_user
```
an anonymous user can still read the package. Further on anonymous users can always read the package-index (`conan search...`) as those seem to be handled as ref belonging to user `None`. On the other hand, if you are logged in as some other user "some_user2", you will be denied to read the package.

This behaviour is due to a shortcut in `authorize.py` (in conans/server/service)  which reads
```
class BasicAuthorizer(Authorizer):
    """
    Reads permissions from the config file (server.cfg)
    """
....
    def check_read_conan(self, username, ref):
        """
        username: User that request to read the conans
        ref: ConanFileReference
        """
        if ref.user == username:
            return

        self._check_any_rule_ok(username, self.read_permissions, ref)
```
Here you can clearly see that rules are not checked for such cases.

This is quite unexpected, not clearly documented and this potentially dangerous.
Anonymous users will always be able to search server-contents and download packages without user/channel - no matter what the server.conf says.


Discussion:
Hi @hlewin 

Yes, this looks like it could be an issue. As packages without user/channel were introduced later, this was probably not completely considered in conan_server.

While this is investigated, these would be some recommendations:
- In general, user packages should have a ``user/channel``. Only packages from ConanCenter don't have user/channel, and those are public.
- You can use an ArtifactoryCE. It is completely free, easy to run, it decouples permission from the ``user/channel`` user name, it has a web UI, and it can scale much better than ``conan_server``.
I've encountered this as well while writing a custom Authorizer. Is it also recommended to specify user and channel for packages shared within a company? What if the owner should be a team and not a specific person?

ArtifactoryCE is not suitable for me because I need custom authentication & authorization.

It's difficult to trace the calls because of how Bottle works, could you direct me to where API calls are made with user None before authenticating?
It would be smart if I did the trace on the client instead of the server...

Anyway it seems to be [here](https://github.com/conan-io/conan/blob/develop/conans/client/rest/auth_manager.py#L40) that `get_recipe_manifest` is the first thing called without authentication. I'm going to experiment with this file and see if I can make it always auth.
Adding `self._auth_manager.call_rest_api_method(remote, "check_credentials")` above https://github.com/conan-io/conan/blob/develop/conans/client/remote_manager.py#L290 seems to work.
> Is it also recommended to specify user and channel for packages shared within a company? What if the owner should be a team and not a specific person?

Yes, it is good practice to setup user/channel (in 2.0 only user is possible), for your own packages. Typically named after the company or team, depending on the scope, and occasionally after individual developers just while playing around with new dependencies and probably only uploading to "playground" repositories, not to develop/production

> Adding self._auth_manager.call_rest_api_method(remote, "check_credentials") above https://github.com/conan-io/conan/blob/develop/conans/client/remote_manager.py#L290 seems to work.

This would introduce an extra duplicated auth API call before every single API requests, doubling communication times, this doesn't sound possible. It seems that this should be addressed in the server side. 
You are right, this is at best a hack. But for reasons I can't explain, this is working now even without that change.

My auth system provides the username in a header. Before, my Authorizer was receiving None user during the call to `get_recipe_manifest_url`, raising AuthenticationException, and prompting the client for credentials. Now, it is correctly receiving the username in the header and not raising an exception. And username/password auth via the HTTP Basic and JWT plugins still works.
Sorry this was not followed up back then.

I have opened https://github.com/conan-io/conan/pull/17132 that will close this ticket for next Conan 2.9



Patch:
diff --git a/conans/client/downloaders/file_downloader.py b/conans/client/downloaders/file_downloader.py
index 19ac00b400c..c71f1455315 100644
--- a/conans/client/downloaders/file_downloader.py
+++ b/conans/client/downloaders/file_downloader.py
@@ -84,7 +84,7 @@ def _download_file(self, url, auth, headers, file_path, verify_ssl, try_resume=F
             if response.status_code == 404:
                 raise NotFoundException("Not found: %s" % url)
             elif response.status_code == 403:
-                if auth is None or (hasattr(auth, "token") and auth.token is None):
+                if auth is None or (hasattr(auth, "bearer") and auth.bearer is None):
                     # TODO: This is a bit weird, why this conversion? Need to investigate
                     raise AuthenticationException(response_to_str(response))
                 raise ForbiddenException(response_to_str(response))
diff --git a/conans/client/rest/auth_manager.py b/conans/client/rest/auth_manager.py
index e67745ba8e4..f69e1d6ec35 100644
--- a/conans/client/rest/auth_manager.py
+++ b/conans/client/rest/auth_manager.py
@@ -7,8 +7,7 @@
 Flow:
     Directly invoke a REST method in RestApiClient, example: get_conan.
     if receives AuthenticationException (not open method) will ask user for login and password
-    and will invoke RestApiClient.get_token() (with LOGIN_RETRIES retries) and retry to call
-    get_conan with the new token.
+    (with LOGIN_RETRIES retries) and retry to call with the new token.
 """
 
 from conan.api.output import ConanOutput
diff --git a/conans/client/rest/file_uploader.py b/conans/client/rest/file_uploader.py
index 13dd9255f58..429150581b0 100644
--- a/conans/client/rest/file_uploader.py
+++ b/conans/client/rest/file_uploader.py
@@ -26,7 +26,7 @@ def _handle_400_response(response, auth):
             raise AuthenticationException(response_to_str(response))
 
         if response.status_code == 403:
-            if auth is None or auth.token is None:
+            if auth is None or auth.bearer is None:
                 raise AuthenticationException(response_to_str(response))
             raise ForbiddenException(response_to_str(response))
 
diff --git a/conans/server/service/authorize.py b/conans/server/service/authorize.py
index a6ce3df90d6..67150205f25 100644
--- a/conans/server/service/authorize.py
+++ b/conans/server/service/authorize.py
@@ -127,9 +127,6 @@ def check_read_conan(self, username, ref):
         username: User that request to read the conans
         ref: RecipeReference
         """
-        if ref.user == username:
-            return
-
         self._check_any_rule_ok(username, self.read_permissions, ref)
 
     def check_write_conan(self, username, ref):
@@ -137,9 +134,6 @@ def check_write_conan(self, username, ref):
         username: User that request to write the conans
         ref: RecipeReference
         """
-        if ref.user == username:
-            return True
-
         self._check_any_rule_ok(username, self.write_permissions, ref)
 
     def check_delete_conan(self, username, ref):


Test patch:
diff --git a/test/integration/remote/rest_api_test.py b/test/integration/remote/rest_api_test.py
index cbb87dcd161..014af5b00b2 100644
--- a/test/integration/remote/rest_api_test.py
+++ b/test/integration/remote/rest_api_test.py
@@ -32,7 +32,11 @@ class RestApiTest(unittest.TestCase):
     def setUpClass(cls):
         if not cls.server:
             with environment_update({"CONAN_SERVER_PORT": str(get_free_port())}):
-                cls.server = TestServerLauncher(server_capabilities=['ImCool', 'TooCool'])
+                read_perms = [("*/*@*/*", "private_user")]
+                write_perms = [("*/*@*/*", "private_user")]
+                cls.server = TestServerLauncher(server_capabilities=['ImCool', 'TooCool'],
+                                                read_permissions=read_perms,
+                                                write_permissions=write_perms)
                 cls.server.start()
 
                 filename = os.path.join(temp_folder(), "conan.conf")
diff --git a/test/integration/remote/retry_test.py b/test/integration/remote/retry_test.py
index dd3e859815b..5653f65621e 100644
--- a/test/integration/remote/retry_test.py
+++ b/test/integration/remote/retry_test.py
@@ -73,7 +73,7 @@ def test_error_403_forbidden(self):
             uploader = FileUploader(requester=_RequesterMock(403, "content"),
                                     verify=False, config=_ConfigMock())
             with self.assertRaisesRegex(ForbiddenException, "content"):
-                auth = namedtuple("auth", "token")
+                auth = namedtuple("auth", "bearer")
                 uploader.upload(url="fake", abs_path=self.filename, retry=2, auth=auth("token"))
             output_lines = output.getvalue().splitlines()
             counter = Counter(output_lines)
@@ -86,7 +86,7 @@ def test_error_403_authentication(self):
             uploader = FileUploader(requester=_RequesterMock(403, "content"),
                                     verify=False, config=_ConfigMock())
             with self.assertRaisesRegex(AuthenticationException, "content"):
-                auth = namedtuple("auth", "token")
+                auth = namedtuple("auth", "bearer")
                 uploader.upload(url="fake", abs_path=self.filename, retry=2, auth=auth(None))
             output_lines = output.getvalue().splitlines()
             counter = Counter(output_lines)
diff --git a/test/unittests/client/rest/uploader_test.py b/test/unittests/client/rest/uploader_test.py
index 4777ccd2121..e8a6fbb71af 100644
--- a/test/unittests/client/rest/uploader_test.py
+++ b/test/unittests/client/rest/uploader_test.py
@@ -37,7 +37,7 @@ def test_401_raises_unauthoirzed_exception(self):
             uploader.upload("fake_url", self.f)
 
     def test_403_raises_unauthoirzed_exception_if_no_token(self):
-        auth = namedtuple("auth", "token")(None)
+        auth = namedtuple("auth", "bearer")(None)
         uploader = FileUploader(MockRequester(403), verify=False, config=_ConfigMock())
         with self.assertRaisesRegex(AuthenticationException, "tururu"):
             uploader.upload("fake_url", self.f, auth=auth)
@@ -48,7 +48,7 @@ def test_403_raises_unauthorized_exception_if_no_auth(self):
             uploader.upload("fake_url", self.f)
 
     def test_403_raises_forbidden_exception_if_token(self):
-        auth = namedtuple("auth", "token")("SOMETOKEN")
+        auth = namedtuple("auth", "bearer")("SOMETOKEN")
         uploader = FileUploader(MockRequester(403), verify=False, config=_ConfigMock())
         with self.assertRaisesRegex(ForbiddenException, "tururu"):
             uploader.upload("fake_url", self.f, auth=auth)
diff --git a/test/unittests/server/service/authorizer_test.py b/test/unittests/server/service/authorizer_test.py
index 1c80b8fe7af..1424dad615b 100644
--- a/test/unittests/server/service/authorizer_test.py
+++ b/test/unittests/server/service/authorizer_test.py
@@ -86,7 +86,7 @@ def test_permissions(self):
         # Only lasote can read it but other conans can be readed
         read_perms = [(str(self.openssl_ref), "lasote"), ("*/*@*/*", "*")]
         # Only pepe (and lasote because its owner) can write it and no more users can write
-        write_perms = [(str(self.openssl_ref), "pepe")]
+        write_perms = [(str(self.openssl_ref), "pepe, lasote")]
 
         authorizer = BasicAuthorizer(read_perms, write_perms)
 
@@ -185,12 +185,11 @@ def test_users(self):
         # Simple user list
         read_perms = [("openssl/*@lasote/testing", "user1,user2,user3")]
         authorizer = BasicAuthorizer(read_perms, [])
-        for u in ['user1','user2','user3']:
+        for u in ['user1', 'user2', 'user3']:
             authorizer.check_read_conan(u, self.openssl_ref)
 
         # Spaces bewteen user names should be ignored
         read_perms = [("openssl/*@lasote/testing", "user1 , user2,\tuser3")]
         authorizer = BasicAuthorizer(read_perms, [])
-        for u in ['user1','user2','user3']:
+        for u in ['user1', 'user2', 'user3']:
             authorizer.check_read_conan(u, self.openssl_ref)
-
diff --git a/test/unittests/server/service/service_test.py b/test/unittests/server/service/service_test.py
index 97c90f33c94..8e3e1f7d413 100644
--- a/test/unittests/server/service/service_test.py
+++ b/test/unittests/server/service/service_test.py
@@ -39,7 +39,7 @@ def setUp(self):
         self.tmp_dir = temp_folder()
 
         read_perms = [("*/*@*/*", "*")]
-        write_perms = []
+        write_perms = [("*/*@*/*", "*")]
         authorizer = BasicAuthorizer(read_perms, write_perms)
 
         self.fake_url = "http://url"