← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:access-token-api into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:access-token-api into launchpad:master.

Commit message:
Add webservice API for personal access tokens

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/410163

This currently supports issuing, querying, and revoking personal access tokens for Git repositories.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:access-token-api into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 7c3c549..204fa61 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -139,6 +139,7 @@ from lp.registry.interfaces.sourcepackage import (
 from lp.registry.interfaces.ssh import ISSHKey
 from lp.registry.interfaces.teammembership import ITeamMembership
 from lp.registry.interfaces.wikiname import IWikiName
+from lp.services.auth.interfaces import IAccessToken
 from lp.services.comments.interfaces.conversation import IComment
 from lp.services.fields import InlineObject
 from lp.services.messages.interfaces.message import (
@@ -691,6 +692,9 @@ patch_collection_property(
 patch_collection_property(
     IHasSpecifications, 'api_valid_specifications', ISpecification)
 
+# IAccessToken
+patch_reference_property(IAccessToken, 'git_repository', IGitRepository)
+
 
 ###
 #
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index dec0bf6..363356d 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -85,6 +85,8 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.role import IPersonRoles
+from lp.services.auth.enums import AccessTokenScope
+from lp.services.auth.interfaces import IAccessTokenTarget
 from lp.services.fields import (
     InlineObject,
     PersonChoice,
@@ -690,20 +692,41 @@ class IGitRepositoryView(IHasRecipes):
         :return: A `ResultSet` of `IGitActivity`.
         """
 
+    # XXX cjwatson 2021-10-13: This should move to IAccessTokenTarget, but
+    # currently has rather too much backward-compatibility code for that.
+    @operation_parameters(
+        description=TextLine(
+            title=_("A short description of the token."), required=False),
+        scopes=List(
+            title=_("A list of scopes to be granted by this token."),
+            value_type=Choice(vocabulary=AccessTokenScope), required=False),
+        date_expires=Datetime(
+            title=_("When the token should expire."), required=False))
     @export_write_operation()
     @operation_for_version("devel")
-    def issueAccessToken():
+    def issueAccessToken(description=None, scopes=None, date_expires=None):
         """Issue an access token for this repository.
 
         Access tokens can be used to push to this repository over HTTPS.
         They are only valid for a single repository, and have a short expiry
-        period (currently one week), so at the moment they are only suitable
-        in some limited situations.
+        period (currently fixed at one week), so at the moment they are only
+        suitable in some limited situations.  By default they are currently
+        implemented as macaroons.
+
+        If `description` and `scopes` are both given, then issue a personal
+        access token instead, either non-expiring or with an expiry time
+        given by `date_expires`.  These may be used in webservice API
+        requests for certain methods on this repository.
 
         This interface is experimental, and may be changed or removed
         without notice.
 
-        :return: A serialised macaroon.
+        :return: If `description` and `scopes` are both given, the secret
+            for a new personal access token (Launchpad only records the hash
+            of this secret and not the secret itself, so the caller must be
+            careful to save this; personal access tokens are in development
+            and may not entirely work yet).  Otherwise, a serialised
+            macaroon.
         """
 
 
@@ -782,7 +805,7 @@ class IGitRepositoryExpensiveRequest(Interface):
         that is not an admin or a registry expert."""
 
 
-class IGitRepositoryEdit(IWebhookTarget):
+class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
     """IGitRepository methods that require launchpad.Edit permission."""
 
     @mutator_for(IGitRepositoryView["name"])
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 8fb7cda..99d451b 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -177,6 +177,9 @@ from lp.registry.model.accesspolicy import (
     )
 from lp.registry.model.person import Person
 from lp.registry.model.teammembership import TeamParticipation
+from lp.services.auth.interfaces import IAccessTokenSet
+from lp.services.auth.model import AccessTokenTargetMixin
+from lp.services.auth.utils import create_access_token_secret
 from lp.services.config import config
 from lp.services.database import bulk
 from lp.services.database.constants import (
@@ -290,7 +293,8 @@ def git_repository_modified(repository, event):
 
 
 @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
-class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
+class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
+                    GitIdentityMixin):
     """See `IGitRepository`."""
 
     __storm_table__ = 'GitRepository'
@@ -1580,19 +1584,43 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         return DecoratedResultSet(
             results, pre_iter_hook=preloadDataForActivities)
 
-    def issueAccessToken(self):
-        """See `IGitRepository`."""
+    def _issuePersonalAccessToken(self, user, description, scopes,
+                                  date_expires=None):
+        """Issue a personal access token for this repository."""
+        if user is None:
+            raise Unauthorized(
+                "Personal access tokens may only be issued for a logged-in "
+                "user.")
+        secret = create_access_token_secret()
+        getUtility(IAccessTokenSet).new(
+            secret, owner=user, description=description, target=self,
+            scopes=scopes, date_expires=date_expires)
+        return secret
+
+    # XXX cjwatson 2021-10-13: Remove this once lp.code.xmlrpc.git accepts
+    # pushes using personal access tokens.
+    def _issueMacaroon(self, user):
+        """Issue a macaroon for this repository."""
         issuer = getUtility(IMacaroonIssuer, "git-repository")
+        # Our security adapter has already done the checks we need, apart
+        # from forbidding anonymous users which is done by the issuer.
+        return removeSecurityProxy(issuer).issueMacaroon(
+            self, user=user).serialize()
+
+    def issueAccessToken(self, owner=None, description=None, scopes=None,
+                         date_expires=None):
+        """See `IGitRepository`."""
         # It's more usual in model code to pass the user as an argument,
         # e.g. using @call_with(user=REQUEST_USER) in the webservice
         # interface.  However, in this case that would allow anyone who
         # constructs a way to call this method not via the webservice to
         # issue a token for any user, which seems like a bad idea.
         user = getUtility(ILaunchBag).user
-        # Our security adapter has already done the checks we need, apart
-        # from forbidding anonymous users which is done by the issuer.
-        return removeSecurityProxy(issuer).issueMacaroon(
-            self, user=user).serialize()
+        if description is not None and scopes is not None:
+            return self._issuePersonalAccessToken(
+                user, description, scopes, date_expires=date_expires)
+        else:
+            return self._issueMacaroon(user)
 
     def canBeDeleted(self):
         """See `IGitRepository`."""
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 78c3353..a832554 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -143,6 +143,8 @@ from lp.registry.interfaces.persondistributionsourcepackage import (
 from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
+from lp.services.auth.enums import AccessTokenScope
+from lp.services.auth.interfaces import IAccessTokenSet
 from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
@@ -4717,6 +4719,76 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
             b"git-repository macaroons may only be issued for a logged-in "
             b"user.", response.body)
 
+    def test_issueAccessToken_personal(self):
+        # A user can request a personal access token via the webservice API.
+        repository = self.factory.makeGitRepository()
+        # Write access to the repository isn't checked at this stage
+        # (although the access token will only be useful if the user has
+        # some kind of write access).
+        requester = self.factory.makePerson()
+        with person_logged_in(requester):
+            repository_url = api_url(repository)
+        webservice = webservice_for_person(
+            requester, permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel")
+        response = webservice.named_post(
+            repository_url, "issueAccessToken", description="Test token",
+            scopes=["repository:build_status"])
+        self.assertEqual(200, response.status)
+        secret = response.jsonBody()
+        with person_logged_in(requester):
+            token = getUtility(IAccessTokenSet).getBySecret(secret)
+            self.assertThat(token, MatchesStructure(
+                owner=Equals(requester),
+                description=Equals("Test token"),
+                target=Equals(repository),
+                scopes=Equals([AccessTokenScope.REPOSITORY_BUILD_STATUS]),
+                date_expires=Is(None)))
+
+    def test_issueAccessToken_personal_with_expiry(self):
+        # A user can set an expiry time when requesting a personal access
+        # token via the webservice API.
+        repository = self.factory.makeGitRepository()
+        # Write access to the repository isn't checked at this stage
+        # (although the access token will only be useful if the user has
+        # some kind of write access).
+        requester = self.factory.makePerson()
+        with person_logged_in(requester):
+            repository_url = api_url(repository)
+        webservice = webservice_for_person(
+            requester, permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel")
+        date_expires = datetime.now(pytz.UTC) + timedelta(days=30)
+        response = webservice.named_post(
+            repository_url, "issueAccessToken", description="Test token",
+            scopes=["repository:build_status"],
+            date_expires=date_expires.isoformat())
+        self.assertEqual(200, response.status)
+        secret = response.jsonBody()
+        with person_logged_in(requester):
+            token = getUtility(IAccessTokenSet).getBySecret(secret)
+            self.assertThat(token, MatchesStructure.byEquality(
+                owner=requester,
+                description="Test token",
+                target=repository,
+                scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS],
+                date_expires=date_expires))
+
+    def test_issueAccessToken_personal_anonymous(self):
+        # An anonymous user cannot request a personal access token via the
+        # webservice API.
+        repository = self.factory.makeGitRepository()
+        with person_logged_in(repository.owner):
+            repository_url = api_url(repository)
+        webservice = webservice_for_person(None, default_api_version="devel")
+        response = webservice.named_post(
+            repository_url, "issueAccessToken", description="Test token",
+            scopes=["repository:build_status"])
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            b"Personal access tokens may only be issued for a logged-in user.",
+            response.body)
+
 
 class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
     """Test GitRepository macaroon issuing and verification."""
diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
index bd76aa6..f300218 100644
--- a/lib/lp/services/auth/configure.zcml
+++ b/lib/lp/services/auth/configure.zcml
@@ -4,7 +4,9 @@
 
 <configure
     xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
     xmlns:i18n="http://namespaces.zope.org/i18n";
+    xmlns:webservice="http://namespaces.canonical.com/webservice";
     i18n_domain="launchpad">
 
     <class class="lp.services.auth.model.AccessToken">
@@ -23,4 +25,11 @@
         provides="lp.services.auth.interfaces.IAccessTokenSet">
         <allow interface="lp.services.auth.interfaces.IAccessTokenSet" />
     </securedutility>
+
+    <browser:url
+        for="lp.services.auth.interfaces.IAccessToken"
+        path_expression="string:+access-token/${id}"
+        attribute_to_parent="target" />
+
+    <webservice:register module="lp.services.auth.webservice" />
 </configure>
diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
index b8e3e7a..dabd794 100644
--- a/lib/lp/services/auth/interfaces.py
+++ b/lib/lp/services/auth/interfaces.py
@@ -7,9 +7,20 @@ __metaclass__ = type
 __all__ = [
     "IAccessToken",
     "IAccessTokenSet",
+    "IAccessTokenTarget",
     "IAccessTokenVerifiedRequest",
     ]
 
+from lazr.restful.declarations import (
+    call_with,
+    export_read_operation,
+    export_write_operation,
+    exported,
+    exported_as_webservice_entry,
+    operation_for_version,
+    operation_returns_collection_of,
+    REQUEST_USER,
+    )
 from lazr.restful.fields import Reference
 from zope.interface import Interface
 from zope.schema import (
@@ -22,54 +33,67 @@ from zope.schema import (
     )
 
 from lp import _
-from lp.code.interfaces.gitrepository import IGitRepository
 from lp.services.auth.enums import AccessTokenScope
 from lp.services.fields import PublicPersonChoice
+from lp.services.webservice.apihelpers import patch_reference_property
 
 
+# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
+# generation working.  Individual attributes must set their version to
+# "devel".
+@exported_as_webservice_entry(as_of="beta")
 class IAccessToken(Interface):
     """A personal access token for the webservice API."""
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_created = Datetime(
-        title=_("When the token was created."), required=True, readonly=True)
+    date_created = exported(Datetime(
+        title=_("When the token was created."), required=True, readonly=True))
 
-    owner = PublicPersonChoice(
+    owner = exported(PublicPersonChoice(
         title=_("The person who created the token."),
-        vocabulary="ValidPersonOrTeam", required=True, readonly=True)
+        vocabulary="ValidPersonOrTeam", required=True, readonly=True))
 
-    description = TextLine(
-        title=_("A short description of the token."), required=True)
+    description = exported(TextLine(
+        title=_("A short description of the token."), required=True))
 
     git_repository = Reference(
         title=_("The Git repository for which the token was issued."),
-        schema=IGitRepository, required=True, readonly=True)
+        # Really IGitRepository, patched in _schema_circular_imports.py.
+        schema=Interface, required=True, readonly=True)
+
+    target = exported(Reference(
+        title=_("The target for which the token was issued."),
+        # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
+        schema=Interface, required=True, readonly=True))
 
-    scopes = List(
+    scopes = exported(List(
         value_type=Choice(vocabulary=AccessTokenScope),
         title=_("A list of scopes granted by the token."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    date_last_used = Datetime(
+    date_last_used = exported(Datetime(
         title=_("When the token was last used."),
-        required=False, readonly=False)
+        required=False, readonly=False))
 
-    date_expires = Datetime(
+    date_expires = exported(Datetime(
         title=_("When the token should expire or was revoked."),
-        required=False, readonly=False)
+        required=False, readonly=False))
 
     is_expired = Bool(
         title=_("Whether this token has expired."),
         required=False, readonly=True)
 
-    revoked_by = PublicPersonChoice(
+    revoked_by = exported(PublicPersonChoice(
         title=_("The person who revoked the token, if any."),
-        vocabulary="ValidPersonOrTeam", required=False, readonly=False)
+        vocabulary="ValidPersonOrTeam", required=False, readonly=False))
 
     def updateLastUsed():
         """Update this token's last-used date, if possible."""
 
+    @call_with(revoked_by=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version("devel")
     def revoke(revoked_by):
         """Revoke this token."""
 
@@ -77,7 +101,7 @@ class IAccessToken(Interface):
 class IAccessTokenSet(Interface):
     """The set of all personal access tokens."""
 
-    def new(secret, owner, description, target, scopes):
+    def new(secret, owner, description, target, scopes, date_expires=None):
         """Return a new access token with a given secret.
 
         :param secret: A text string.
@@ -87,6 +111,8 @@ class IAccessTokenSet(Interface):
             issued.
         :param scopes: A list of `AccessTokenScope`s to be granted by the
             token.
+        :param date_expires: The time when this token should expire, or
+            None.
         """
 
     def getBySecret(secret):
@@ -101,12 +127,32 @@ class IAccessTokenSet(Interface):
         :param owner: An `IPerson`.
         """
 
-    def findByTarget(target):
+    def findByTarget(target, visible_by_user=None):
         """Return all access tokens for this target.
 
-        :param target: An `IGitRepository`.
+        :param target: An `IAccessTokenTarget`.
+        :param visible_by_user: If given, return only access tokens visible
+            by this user.
         """
 
 
 class IAccessTokenVerifiedRequest(Interface):
     """Marker interface for a request with a verified access token."""
+
+
+# XXX cjwatson 2021-10-13 bug=760849: "beta" is a lie to get WADL
+# generation working.  Individual attributes must set their version to
+# "devel".
+@exported_as_webservice_entry(as_of="beta")
+class IAccessTokenTarget(Interface):
+    """An object that can be a target for access tokens."""
+
+    @call_with(visible_by_user=REQUEST_USER)
+    @operation_returns_collection_of(IAccessToken)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getAccessTokens(visible_by_user=None):
+        """Return personal access tokens for this target."""
+
+
+patch_reference_property(IAccessToken, "target", IAccessTokenTarget)
diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
index 437be7e..0b84f70 100644
--- a/lib/lp/services/auth/model.py
+++ b/lib/lp/services/auth/model.py
@@ -6,6 +6,7 @@
 __metaclass__ = type
 __all__ = [
     "AccessToken",
+    "AccessTokenTargetMixin",
     ]
 
 from datetime import (
@@ -20,6 +21,7 @@ from storm.expr import (
     And,
     Cast,
     Or,
+    Select,
     SQL,
     Update,
     )
@@ -29,9 +31,13 @@ from storm.locals import (
     Reference,
     Unicode,
     )
+from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
+from lp.code.interfaces.gitcollection import IAllGitRepositories
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.model.teammembership import TeamParticipation
 from lp.services.auth.enums import AccessTokenScope
 from lp.services.auth.interfaces import (
     IAccessToken,
@@ -78,7 +84,8 @@ class AccessToken(StormBase):
 
     resolution = timedelta(minutes=10)
 
-    def __init__(self, secret, owner, description, target, scopes):
+    def __init__(self, secret, owner, description, target, scopes,
+                 date_expires=None):
         """Construct an `AccessToken`."""
         self._token_sha256 = hashlib.sha256(secret.encode()).hexdigest()
         self.owner = owner
@@ -89,6 +96,7 @@ class AccessToken(StormBase):
             raise TypeError("Unsupported target: {!r}".format(target))
         self.scopes = scopes
         self.date_created = UTC_NOW
+        self.date_expires = date_expires
 
     @property
     def target(self):
@@ -139,10 +147,13 @@ class AccessToken(StormBase):
 @implementer(IAccessTokenSet)
 class AccessTokenSet:
 
-    def new(self, secret, owner, description, target, scopes):
+    def new(self, secret, owner, description, target, scopes,
+            date_expires=None):
         """See `IAccessTokenSet`."""
         store = IStore(AccessToken)
-        token = AccessToken(secret, owner, description, target, scopes)
+        token = AccessToken(
+            secret, owner, description, target, scopes,
+            date_expires=date_expires)
         store.add(token)
         return token
 
@@ -156,11 +167,30 @@ class AccessTokenSet:
         """See `IAccessTokenSet`."""
         return IStore(AccessToken).find(AccessToken, owner=owner)
 
-    def findByTarget(self, target):
+    def findByTarget(self, target, visible_by_user=None):
         """See `IAccessTokenSet`."""
-        kwargs = {}
+        clauses = []
         if IGitRepository.providedBy(target):
-            kwargs["git_repository"] = target
+            clauses.append(AccessToken.git_repository == target)
+            if visible_by_user is not None:
+                collection = getUtility(IAllGitRepositories).visibleByUser(
+                    visible_by_user).ownedByTeamMember(visible_by_user)
+                ids = collection.getRepositoryIds()
+                clauses.append(Or(
+                    AccessToken.owner_id.is_in(Select(
+                        TeamParticipation.teamID,
+                        where=TeamParticipation.person == visible_by_user.id)),
+                    AccessToken.git_repository_id.is_in(
+                        removeSecurityProxy(ids)._get_select())))
         else:
             raise TypeError("Unsupported target: {!r}".format(target))
-        return IStore(AccessToken).find(AccessToken, **kwargs)
+        return IStore(AccessToken).find(AccessToken, *clauses)
+
+
+class AccessTokenTargetMixin:
+    """Mix this into classes that implement `IAccessTokenTarget`."""
+
+    def getAccessTokens(self, visible_by_user=None):
+        """See `IAccessTokenTarget`."""
+        return getUtility(IAccessTokenSet).findByTarget(
+            self, visible_by_user=visible_by_user)
diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
index f97d498..32dcf28 100644
--- a/lib/lp/services/auth/tests/test_model.py
+++ b/lib/lp/services/auth/tests/test_model.py
@@ -31,12 +31,18 @@ from lp.services.database.sqlbase import (
     get_transaction_timestamp,
     )
 from lp.services.webapp.authorization import check_permission
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
+    api_url,
     login,
     login_person,
+    person_logged_in,
+    record_two_runs,
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import webservice_for_person
 
 
 class TestAccessToken(TestCaseWithFactory):
@@ -219,3 +225,84 @@ class TestAccessTokenSet(TestCaseWithFactory):
             [tokens[2]], getUtility(IAccessTokenSet).findByTarget(targets[1]))
         self.assertContentEqual(
             [], getUtility(IAccessTokenSet).findByTarget(targets[2]))
+
+    def test_findByTarget_visible_by_user(self):
+        targets = [self.factory.makeGitRepository() for _ in range(3)]
+        owners = [self.factory.makePerson() for _ in range(3)]
+        tokens = [
+            self.factory.makeAccessToken(
+                owner=owners[owner_index], target=targets[target_index])[1]
+            for owner_index, target_index in (
+                (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
+        for owner_index, target_index, expected_tokens in (
+                (0, 0, tokens[:2]),
+                (0, 1, []),
+                (0, 2, []),
+                (1, 0, [tokens[2]]),
+                (1, 1, [tokens[3]]),
+                (1, 2, []),
+                (2, 0, []),
+                (2, 1, [tokens[4]]),
+                (2, 2, []),
+                ):
+            self.assertContentEqual(
+                expected_tokens,
+                getUtility(IAccessTokenSet).findByTarget(
+                    targets[target_index],
+                    visible_by_user=owners[owner_index]))
+
+
+class TestAccessTokenTargetBase:
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.target = self.makeTarget()
+        self.owner = self.target.owner
+        self.target_url = api_url(self.target)
+        self.webservice = webservice_for_person(
+            self.owner, permission=OAuthPermission.WRITE_PRIVATE)
+
+    def test_getAccessTokens(self):
+        with person_logged_in(self.owner):
+            for description in ("Test token 1", "Test token 2"):
+                self.factory.makeAccessToken(
+                    owner=self.owner, description=description,
+                    target=self.target)
+        response = self.webservice.named_get(
+            self.target_url, "getAccessTokens", api_version="devel")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(
+            ["Test token 1", "Test token 2"],
+            [entry["description"] for entry in response.jsonBody()["entries"]])
+
+    def test_getAccessTokens_permissions(self):
+        webservice = webservice_for_person(None)
+        response = webservice.named_get(
+            self.target_url, "getAccessTokens", api_version="devel")
+        self.assertEqual(401, response.status)
+        self.assertIn(b"launchpad.Edit", response.body)
+
+    def test_getAccessTokens_query_count(self):
+        def get_tokens():
+            response = self.webservice.named_get(
+                self.target_url, "getAccessTokens", api_version="devel")
+            self.assertEqual(200, response.status)
+            self.assertIn(len(response.jsonBody()["entries"]), {0, 2, 4})
+
+        def create_token():
+            with person_logged_in(self.owner):
+                self.factory.makeAccessToken(
+                    owner=self.owner, target=self.target)
+
+        get_tokens()
+        recorder1, recorder2 = record_two_runs(get_tokens, create_token, 2)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
+
+class TestAccessTokenTargetGitRepository(
+        TestAccessTokenTargetBase, TestCaseWithFactory):
+
+    def makeTarget(self):
+        return self.factory.makeGitRepository()
diff --git a/lib/lp/services/auth/webservice.py b/lib/lp/services/auth/webservice.py
new file mode 100644
index 0000000..08e7ca1
--- /dev/null
+++ b/lib/lp/services/auth/webservice.py
@@ -0,0 +1,14 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Personal access token webservice registrations."""
+
+__all__ = [
+    "IAccessToken",
+    "IAccessTokenTarget",
+    ]
+
+from lp.services.auth.interfaces import (
+    IAccessToken,
+    IAccessTokenTarget,
+    )
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index 96efcf6..03ca4a8 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -166,7 +166,8 @@
             <xsl:with-param name="url">
                 <xsl:choose>
                     <xsl:when test="
-                        @id = 'bug_link_target'
+                        @id = 'access_token_target'
+                        or @id = 'bug_link_target'
                         or @id = 'bug_target'
                         or @id = 'faq_target'
                         or @id = 'git_target'
@@ -190,6 +191,10 @@
     <xsl:template name="find-entry-uri">
         <xsl:value-of select="$base"/>
         <xsl:choose>
+            <xsl:when test="@id = 'access_token'">
+                <xsl:text>[target URL]/+access-token/</xsl:text>
+                <var>&lt;id&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'archive'">
                 <xsl:text>/</xsl:text>
                 <var>&lt;distribution&gt;</var>