← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/launchpad:project-tokens/update-models into launchpad:master

 

Ines Almeida has proposed merging ~ines-almeida/launchpad:project-tokens/update-models into launchpad:master with ~ines-almeida/launchpad:project-tokens/refactor-access-token-tests as a prerequisite.

Commit message:
Update Access Token model and logic to enable Project scoped Access Tokens
    
This change introduces Projects as possible targets for Access Tokens similarly to what already existed for Git Repository, with the difference that a Project Access Token can be used to authenticate requests against any Git Repository within that Project.


Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/451542

Changes in this MP:
 - Added `project` and a field within the `AccessToken` model, enabling Access Tokens to be targeted at Projects (before only Git Repositories could be targets for Access Tokens)
 - Enabled using Project tokens to authenticate against requests similarly as Git Repository Access Tokens worked. Project Access Tokens can be used to authenticate against any of the Project's Git Repositories

Changes not in this MP:
 - No interfaces (UI and API) to add Project Access Tokens are exposed
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:project-tokens/update-models into launchpad:master.
diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py
index 3452fcd..7b450c0 100644
--- a/lib/lp/code/xmlrpc/git.py
+++ b/lib/lp/code/xmlrpc/git.py
@@ -195,7 +195,10 @@ class GitAPI(LaunchpadXMLRPCView):
             or access_token.owner.account_status != AccountStatus.ACTIVE
         ):
             raise faults.Unauthorized()
-        if repository is not None and access_token.target != repository:
+        if repository is not None and (
+            access_token.target != repository
+            and access_token.target != repository.target
+        ):
             raise faults.Unauthorized()
         access_token.updateLastUsed()
         return AccessTokenVerificationResult(access_token)
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
index 15e4f09..ca955f6 100644
--- a/lib/lp/code/xmlrpc/tests/test_git.py
+++ b/lib/lp/code/xmlrpc/tests/test_git.py
@@ -1407,6 +1407,27 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             access_token_id=removeSecurityProxy(token).id,
         )
 
+    def test_confirm_git_repository_with_project_access_token(self):
+        # Similarly to git repository tokens, a project access token cannot be
+        # used to authorize confirming repository creation.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self.factory.makeGitRepository(
+            target=project,
+            owner=requester,
+            status=GitRepositoryStatus.CREATING,
+        )
+        _, token = self.factory.makeAccessToken(
+            owner=requester,
+            target=project,
+            scopes=[AccessTokenScope.REPOSITORY_PUSH],
+        )
+        self.assertConfirmRepoCreationUnauthorized(
+            requester,
+            repository,
+            access_token_id=removeSecurityProxy(token).id,
+        )
+
     def test_abort_repo_creation(self):
         requester = self.factory.makePerson()
         repo = self.factory.makeGitRepository(owner=requester)
@@ -1636,6 +1657,27 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             access_token_id=removeSecurityProxy(token).id,
         )
 
+    def test_abort_git_repository_with_project_access_token(self):
+        # Similarly to git repository tokens, a project access token cannot be
+        # used to authorize aborting repository creation.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self.factory.makeGitRepository(
+            target=project,
+            owner=requester,
+            status=GitRepositoryStatus.CREATING,
+        )
+        _, token = self.factory.makeAccessToken(
+            owner=requester,
+            target=project,
+            scopes=[AccessTokenScope.REPOSITORY_PUSH],
+        )
+        self.assertAbortRepoCreationUnauthorized(
+            requester,
+            repository,
+            access_token_id=removeSecurityProxy(token).id,
+        )
+
     def test_abort_git_repository_creation_of_non_existing_repository(self):
         owner = self.factory.makePerson()
         repo = removeSecurityProxy(self.factory.makeGitRepository(owner=owner))
@@ -2634,6 +2676,110 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                     macaroon_raw=macaroon.serialize(),
                 )
 
+    def _assert_translatePath_expected(
+        self, requester, token, repository, expected_success=True, scope="read"
+    ):
+        with person_logged_in(requester):
+            path = "/%s" % repository.unique_name
+            private = (
+                repository.information_type == InformationType.PRIVATESECURITY
+            )
+
+        login(ANONYMOUS)
+        if expected_success:
+            self.assertTranslates(
+                requester,
+                path,
+                repository,
+                permission=scope,
+                readable=(scope == "read"),
+                writable=(scope == "write"),
+                private=private,
+                access_token_id=removeSecurityProxy(token).id,
+            )
+        else:
+            self.assertUnauthorized(
+                requester,
+                path,
+                permission=scope,
+                access_token_id=removeSecurityProxy(token).id,
+            )
+
+    def test_translatePath_user_project_access_token_pull(self):
+        # A user with a suitable project access token can pull from a
+        # repository that belongs to that project, but not others, even if they
+        # own them.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        with person_logged_in(requester):
+            _, token = self.factory.makeAccessToken(
+                owner=requester,
+                target=project,
+                scopes=[AccessTokenScope.REPOSITORY_PULL],
+            )
+
+        repositories_access = [
+            (self.factory.makeGitRepository(), False),
+            (self.factory.makeGitRepository(owner=requester), False),
+            (self.factory.makeGitRepository(target=project), True),
+            (
+                self.factory.makeGitRepository(
+                    owner=requester, target=project
+                ),
+                True,
+            ),
+            (
+                self.factory.makeGitRepository(
+                    owner=requester,
+                    target=project,
+                    information_type=InformationType.PRIVATESECURITY,
+                ),
+                True,
+            ),
+        ]
+
+        for repository, expected_success in repositories_access:
+            self._assert_translatePath_expected(
+                requester, token, repository, expected_success
+            )
+
+    def test_translatePath_user_project_access_token_push(self):
+        # A user with a suitable project access token can pull from a
+        # repository that belongs to that project, but not others, even if they
+        # own them.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        with person_logged_in(requester):
+            _, token = self.factory.makeAccessToken(
+                owner=requester,
+                target=project,
+                scopes=[AccessTokenScope.REPOSITORY_PUSH],
+            )
+
+        repositories_access = [
+            (self.factory.makeGitRepository(), False),
+            (self.factory.makeGitRepository(owner=requester), False),
+            (
+                self.factory.makeGitRepository(
+                    owner=requester, target=project
+                ),
+                True,
+            ),
+            (
+                self.factory.makeGitRepository(
+                    owner=requester,
+                    target=project,
+                    information_type=InformationType.PRIVATESECURITY,
+                ),
+                True,
+            ),
+        ]
+
+        for repository, expected_success in repositories_access:
+            self._assert_translatePath_expected(
+                requester, token, repository, expected_success, "write"
+            )
+
     def test_translatePath_user_access_token_pull(self):
         # A user with a suitable access token can pull from the
         # corresponding repository, but not others, even if they own them.
@@ -2679,23 +2825,6 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                         access_token_id=removeSecurityProxy(token).id,
                     )
 
-    def test_translatePath_user_access_token_pull_wrong_scope(self):
-        # A user with an access token that does not have the repository:pull
-        # scope cannot pull from the corresponding repository.
-        requester = self.factory.makePerson()
-        repository = self.factory.makeGitRepository(owner=requester)
-        _, token = self.factory.makeAccessToken(
-            owner=requester,
-            target=repository,
-            scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS],
-        )
-        self.assertPermissionDenied(
-            requester,
-            "/%s" % repository.unique_name,
-            permission="read",
-            access_token_id=removeSecurityProxy(token).id,
-        )
-
     def test_translatePath_user_access_token_push(self):
         # A user with a suitable access token can push to the corresponding
         # repository, but not others, even if they own them.
@@ -2743,23 +2872,61 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                         access_token_id=removeSecurityProxy(token).id,
                     )
 
-    def test_translatePath_user_access_token_push_wrong_scope(self):
-        # A user with an access token that does not have the repository:push
-        # scope cannot push to the corresponding repository.
-        requester = self.factory.makePerson()
-        repository = self.factory.makeGitRepository(owner=requester)
+    def _assert_translatePath_permission_denied_wrong_scope(
+        self, requester, repository, token_target, scope
+    ):
         _, token = self.factory.makeAccessToken(
             owner=requester,
-            target=repository,
-            scopes=[AccessTokenScope.REPOSITORY_PULL],
+            target=token_target,
+            scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS],
         )
         self.assertPermissionDenied(
             requester,
             "/%s" % repository.unique_name,
-            permission="write",
+            permission=scope,
             access_token_id=removeSecurityProxy(token).id,
         )
 
+    def test_translatePath_user_git_access_token_pull_wrong_scope(self):
+        # A user with a git repository access token that does not have the
+        # repository:pull scope cannot pull from the corresponding repository.
+        requester = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=requester)
+        self._assert_translatePath_permission_denied_wrong_scope(
+            requester, repository, token_target=repository, scope="read"
+        )
+
+    def test_translatePath_user_project_access_token_pull_wrong_scope(self):
+        # A user with a project access token that does not have the
+        # repository:pull scope cannot pull from the corresponding repository.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self.factory.makeGitRepository(
+            owner=requester, target=project
+        )
+        self._assert_translatePath_permission_denied_wrong_scope(
+            requester, repository, token_target=project, scope="read"
+        )
+
+    def test_translatePath_user_git_access_token_push_wrong_scope(self):
+        # A user with a git repository access token that does not have the
+        # repository:push scope cannot push to the corresponding repository.
+        requester = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=requester)
+        self._assert_translatePath_permission_denied_wrong_scope(
+            requester, repository, token_target=repository, scope="write"
+        )
+
+    def test_translatePath_user_project_access_token_push_wrong_scope(self):
+        # A user with a project access token that does not have the
+        # repository:push scope cannot push to the corresponding repository.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self.factory.makeGitRepository(target=project)
+        self._assert_translatePath_permission_denied_wrong_scope(
+            requester, repository, token_target=project, scope="write"
+        )
+
     def test_translatePath_user_access_token_nonexistent(self):
         # Attempting to pass a nonexistent access token ID returns
         # Unauthorized.
@@ -2927,15 +3094,12 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                     auth_params,
                 )
 
-    def test_getMergeProposalURL_user_access_token(self):
-        # The merge proposal URL is returned by LP for a non-default branch
-        # pushed by a user with a suitable access token that has their
-        # ordinary privileges on the corresponding repository.
-        requester = self.factory.makePerson()
-        repository = self._makeGitRepositoryWithRefs(owner=requester)
+    def _assert_getMergeProposalURL_user_access_token(
+        self, requester, repository, token_target
+    ):
         _, token = self.factory.makeAccessToken(
             owner=requester,
-            target=repository,
+            target=token_target,
             scopes=[AccessTokenScope.REPOSITORY_PUSH],
         )
         auth_params = _make_auth_params(
@@ -2943,13 +3107,35 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
         )
         self.assertHasMergeProposalURL(repository, "branch", auth_params)
 
-    def test_getMergeProposalURL_user_access_token_wrong_repository(self):
-        # getMergeProposalURL refuses access tokens for a different
-        # repository.
+    def test_getMergeProposalURL_user_git_access_token(self):
+        # The merge proposal URL is returned by LP for a non-default branch
+        # pushed by a user with a suitable git repository access token that has
+        # their ordinary privileges on the corresponding repository.
         requester = self.factory.makePerson()
         repository = self._makeGitRepositoryWithRefs(owner=requester)
+        self._assert_getMergeProposalURL_user_access_token(
+            requester, repository, token_target=repository
+        )
+
+    def test_getMergeProposalURL_user_project_access_token(self):
+        # The merge proposal URL is returned by LP for a non-default branch
+        # pushed by a user with a suitable project access token that has
+        # their ordinary privileges on the corresponding repository.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self._makeGitRepositoryWithRefs(target=project)
+        self._assert_getMergeProposalURL_user_access_token(
+            requester, repository, token_target=repository
+        )
+
+    def _assert_getMergeProposalURL_user_access_token_wrong_repository(
+        self, requester, token_target
+    ):
+        repository = self._makeGitRepositoryWithRefs(owner=requester)
         _, token = self.factory.makeAccessToken(
-            owner=requester, scopes=[AccessTokenScope.REPOSITORY_PUSH]
+            target=token_target,
+            owner=requester,
+            scopes=[AccessTokenScope.REPOSITORY_PUSH],
         )
         auth_params = _make_auth_params(
             requester, access_token_id=removeSecurityProxy(token).id
@@ -2963,6 +3149,26 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             auth_params,
         )
 
+    def test_getMergeProposalURL_user_git_access_token_wrong_repository(self):
+        # getMergeProposalURL refuses access tokens for a different
+        # repository.
+        requester = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=requester)
+        self._assert_getMergeProposalURL_user_access_token_wrong_repository(
+            requester, token_target=repository
+        )
+
+    def test_getMergeProposalURL_user_project_access_token_wrong_repository(
+        self,
+    ):
+        # getMergeProposalURL refuses access tokens for repository from a
+        # different project.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        self._assert_getMergeProposalURL_user_access_token_wrong_repository(
+            requester, token_target=project
+        )
+
     def test_getMergeProposalURL_code_import(self):
         # A merge proposal URL from LP to Turnip is not returned for
         # code import job as there is no User at the other end.
@@ -3464,12 +3670,15 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                     macaroon.serialize(),
                 )
 
-    def test_authenticateWithPassword_user_access_token(self):
+    def _assert_authenticateWithPassword_user_access_token(
+        self,
+        requester,
+        secret,
+        token,
+    ):
         # A user with a suitable access token can authenticate using it, in
         # which case we return both the access token and the uid for use by
         # later calls.
-        requester = self.factory.makePerson()
-        secret, token = self.factory.makeAccessToken(owner=requester)
         self.assertIsNone(removeSecurityProxy(token).date_last_used)
         self.assertEqual(
             {
@@ -3490,6 +3699,30 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 secret,
             )
 
+    def test_authenticateWithPassword_user_git_access_token(self):
+        # A user with a suitable git repository access token can authenticate
+        # using it
+        requester = self.factory.makePerson()
+        secret, token = self.factory.makeAccessToken(owner=requester)
+        self._assert_authenticateWithPassword_user_access_token(
+            requester,
+            secret,
+            token,
+        )
+
+    def test_authenticateWithPassword_user_project_access_token(self):
+        # A user with a suitable project access token can authenticate using it
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        secret, token = self.factory.makeAccessToken(
+            owner=requester, target=project
+        )
+        self._assert_authenticateWithPassword_user_access_token(
+            requester,
+            secret,
+            token,
+        )
+
     def test_authenticateWithPassword_user_access_token_expired(self):
         # An expired access token is rejected.
         requester = self.factory.makePerson()
@@ -3855,21 +4088,76 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                     macaroon_raw=macaroon.serialize(),
                 )
 
-    def test_checkRefPermissions_user_access_token(self):
+    def _assert_checkRefPermissions_permissions(
+        self, requester, token, repository, expected_success
+    ):
+        ref_path = b"refs/heads/main"
+        login(ANONYMOUS)
+        if expected_success:
+            # This expects that since we don't add any permission rules and the
+            # requester is the owner of the repository, then the call returns
+            # all 3 permissions
+            expected_permissions = ["create", "push", "force_push"]
+        else:
+            expected_permissions = []
+        self.assertHasRefPermissions(
+            requester,
+            repository,
+            [ref_path],
+            {ref_path: expected_permissions},
+            access_token_id=removeSecurityProxy(token).id,
+        )
+
+    def test_checkRefPermissions_user_project_access_token(self):
+        # A user with a suitable access token targetted at a project, has their
+        # ordinary privileges on repositories from the same project, but not
+        # others, even if they own them.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repositories = [
+            (self.factory.makeGitRepository(), False),
+            (self.factory.makeGitRepository(owner=requester), False),
+            (self.factory.makeGitRepository(target=project), True),
+            (
+                self.factory.makeGitRepository(
+                    owner=requester, target=project
+                ),
+                True,
+            ),
+            (
+                self.factory.makeGitRepository(
+                    target=project,
+                    owner=requester,
+                    information_type=InformationType.PRIVATESECURITY,
+                ),
+                True,
+            ),
+        ]
+        with person_logged_in(requester):
+            _, token = self.factory.makeAccessToken(
+                owner=requester,
+                target=project,
+                scopes=[AccessTokenScope.REPOSITORY_PUSH],
+            )
+
+        for repository, expected_sucess in repositories:
+            self._assert_checkRefPermissions_permissions(
+                requester, token, repository, expected_sucess
+            )
+
+    def test_checkRefPermissions_user_git_access_token(self):
         # A user with a suitable access token has their ordinary privileges
         # on the corresponding repository, but not others, even if they own
         # them.
         requester = self.factory.makePerson()
         repositories = [
-            self.factory.makeGitRepository(owner=requester) for _ in range(2)
-        ]
-        repositories.append(
+            self.factory.makeGitRepository(owner=requester),
+            self.factory.makeGitRepository(owner=requester),
             self.factory.makeGitRepository(
                 owner=requester,
                 information_type=InformationType.PRIVATESECURITY,
-            )
-        )
-        ref_path = b"refs/heads/main"
+            ),
+        ]
         tokens = []
         with person_logged_in(requester):
             for repository in repositories:
@@ -3879,29 +4167,21 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                     scopes=[AccessTokenScope.REPOSITORY_PUSH],
                 )
                 tokens.append(token)
+
         for i, repository in enumerate(repositories):
             for j, token in enumerate(tokens):
-                login(ANONYMOUS)
-                if i == j:
-                    expected_permissions = ["create", "push", "force_push"]
-                else:
-                    expected_permissions = []
-                self.assertHasRefPermissions(
-                    requester,
-                    repository,
-                    [ref_path],
-                    {ref_path: expected_permissions},
-                    access_token_id=removeSecurityProxy(token).id,
+                self._assert_checkRefPermissions_permissions(
+                    requester, token, repository, expected_success=i == j
                 )
 
-    def test_checkRefPermissions_user_access_token_wrong_scope(self):
+    def _assert_checkRefPermissions_user_access_token_wrong_scope(
+        self, requester, repository, token_target
+    ):
         # A user with an access token that does not have the repository:push
         # scope cannot push to any branch in the corresponding repository.
-        requester = self.factory.makePerson()
-        repository = self.factory.makeGitRepository(owner=requester)
         _, token = self.factory.makeAccessToken(
             owner=requester,
-            target=repository,
+            target=token_target,
             scopes=[AccessTokenScope.REPOSITORY_PULL],
         )
         ref_path = b"refs/heads/main"
@@ -3913,6 +4193,25 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             access_token_id=removeSecurityProxy(token).id,
         )
 
+    def test_checkRefPermissions_user_git_access_token_wrong_scope(self):
+        # A user with an access token that does not have the repository:push
+        # scope cannot push to any branch in the corresponding repository.
+        requester = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=requester)
+        self._assert_checkRefPermissions_user_access_token_wrong_scope(
+            requester, repository, token_target=repository
+        )
+
+    def test_checkRefPermissions_user_project_access_token_wrong_scope(self):
+        # A user with an access token that does not have the repository:push
+        # scope cannot push to any branch in the corresponding repository.
+        requester = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=requester)
+        repository = self.factory.makeGitRepository(target=project)
+        self._assert_checkRefPermissions_user_access_token_wrong_scope(
+            requester, repository, token_target=project
+        )
+
     def assertUpdatesRepackStats(self, repo):
         start_time = datetime.now(timezone.utc)
         self.assertIsNone(
diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
index 6081830..18c50e3 100644
--- a/lib/lp/services/auth/interfaces.py
+++ b/lib/lp/services/auth/interfaces.py
@@ -73,7 +73,15 @@ class IAccessToken(Interface):
         description=_("The Git repository for which the token was issued."),
         # Really IGitRepository, patched in lp.services.auth.webservice.
         schema=Interface,
-        required=True,
+        required=False,
+        readonly=True,
+    )
+
+    project = Reference(
+        title=_("Project"),
+        description=_("The Project for which the token was issued."),
+        schema=Interface,
+        required=False,
         readonly=True,
     )
 
diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
index f595637..9cc15b2 100644
--- a/lib/lp/services/auth/model.py
+++ b/lib/lp/services/auth/model.py
@@ -21,6 +21,7 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.code.interfaces.gitcollection import IAllGitRepositories
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.product import IProduct
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.auth.enums import AccessTokenScope
 from lp.services.auth.interfaces import IAccessToken, IAccessTokenSet
@@ -50,9 +51,12 @@ class AccessToken(StormBase):
 
     description = Unicode(name="description", allow_none=False)
 
-    git_repository_id = Int(name="git_repository", allow_none=False)
+    git_repository_id = Int(name="git_repository", allow_none=True)
     git_repository = Reference(git_repository_id, "GitRepository.id")
 
+    project_id = Int(name="project", allow_none=True)
+    project = Reference(project_id, "Product.id")
+
     _scopes = JSON(name="scopes", allow_none=False)
 
     date_last_used = DateTime(
@@ -76,6 +80,8 @@ class AccessToken(StormBase):
         self.description = description
         if IGitRepository.providedBy(target):
             self.git_repository = target
+        elif IProduct.providedBy(target):
+            self.project = target
         else:
             raise TypeError("Unsupported target: {!r}".format(target))
         self.scopes = scopes
@@ -85,7 +91,7 @@ class AccessToken(StormBase):
     @property
     def target(self):
         """See `IAccessToken`."""
-        return self.git_repository
+        return self.git_repository or self.project
 
     @property
     def scopes(self):
@@ -205,6 +211,18 @@ class AccessTokenSet:
                         ),
                     )
                 )
+        elif IProduct.providedBy(target):
+            clauses.append(AccessToken.project == target)
+            if visible_by_user is not None:
+                clauses.append(
+                    AccessToken.owner_id.is_in(
+                        Select(
+                            TeamParticipation.team_id,
+                            where=TeamParticipation.person == visible_by_user,
+                        )
+                    )
+                )
+
         else:
             raise TypeError("Unsupported target: {!r}".format(target))
         if not include_expired:
diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
index ca33eec..9c9f7d0 100644
--- a/lib/lp/services/auth/tests/test_model.py
+++ b/lib/lp/services/auth/tests/test_model.py
@@ -197,6 +197,11 @@ class TestAccessTokenGitRepository(TestAccessTokenBase, TestCaseWithFactory):
         return self.factory.makeGitRepository(owner=owner)
 
 
+class TestAccessTokenProduct(TestAccessTokenBase, TestCaseWithFactory):
+    def makeTarget(self, owner=None):
+        return self.factory.makeProduct(owner=owner)
+
+
 class TestAccessTokenSetBase:
     layer = DatabaseFunctionalLayer
 
@@ -436,6 +441,11 @@ class TestGitRepositoryAccessTokenSet(
         return self.factory.makeGitRepository()
 
 
+class TestProjectAccessTokenSet(TestAccessTokenSetBase, TestCaseWithFactory):
+    def makeTarget(self):
+        return self.factory.makeProduct()
+
+
 class TestAccessTokenTargetBase:
     layer = DatabaseFunctionalLayer