Skip to content

Commit b13971d

Browse files
nejchmax-wittig
authored andcommitted
feat(api): support access token rotate API
1 parent 57749d4 commit b13971d

12 files changed

+193
-36
lines changed

docs/gl_objects/group_access_tokens.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,12 @@ Revoke a group access token::
3737
gl.groups.get(1).access_tokens.delete(42)
3838
# or
3939
access_token.delete()
40+
41+
Rotate a group access token and retrieve its new value::
42+
43+
token = group.access_tokens.get(42, lazy=True)
44+
token.rotate()
45+
print(token.token)
46+
# or directly using a token ID
47+
new_token = group.access_tokens.rotate(42)
48+
print(new_token.token)

docs/gl_objects/personal_access_tokens.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ Revoke the personal access token currently used::
5252

5353
gl.personal_access_tokens.delete("self")
5454

55+
Rotate a personal access token and retrieve its new value::
56+
57+
token = gl.personal_access_tokens.get(42, lazy=True)
58+
token.rotate()
59+
print(token.token)
60+
# or directly using a token ID
61+
new_token = gl.personal_access_tokens.rotate(42)
62+
print(new_token.token)
63+
5564
Create a personal access token for a user (admin only)::
5665

5766
user = gl.users.get(25, lazy=True)

docs/gl_objects/project_access_tokens.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,17 @@ Create project access token::
3232

3333
access_token = gl.projects.get(1).access_tokens.create({"name": "test", "scopes": ["api"], "expires_at": "2023-06-06"})
3434

35-
Revoke a project access tokens::
35+
Revoke a project access token::
3636

3737
gl.projects.get(1).access_tokens.delete(42)
3838
# or
3939
access_token.delete()
40+
41+
Rotate a project access token and retrieve its new value::
42+
43+
token = project.access_tokens.get(42, lazy=True)
44+
token.rotate()
45+
print(token.token)
46+
# or directly using a token ID
47+
new_token = project.access_tokens.rotate(42)
48+
print(new_token.token)

gitlab/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@ class GitlabRevertError(GitlabOperationError):
288288
pass
289289

290290

291+
class GitlabRotateError(GitlabOperationError):
292+
pass
293+
294+
291295
class GitlabLicenseError(GitlabOperationError):
292296
pass
293297

@@ -397,6 +401,7 @@ def wrapped_f(*args: Any, **kwargs: Any) -> Any:
397401
"GitlabRestoreError",
398402
"GitlabRetryError",
399403
"GitlabRevertError",
404+
"GitlabRotateError",
400405
"GitlabSearchError",
401406
"GitlabSetError",
402407
"GitlabStopError",

gitlab/mixins.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,65 @@ def download(
653653
)
654654

655655

656+
class RotateMixin(_RestManagerBase):
657+
_computed_path: Optional[str]
658+
_from_parent_attrs: Dict[str, Any]
659+
_obj_cls: Optional[Type[base.RESTObject]]
660+
_parent: Optional[base.RESTObject]
661+
_parent_attrs: Dict[str, Any]
662+
_path: Optional[str]
663+
gitlab: gitlab.Gitlab
664+
665+
@exc.on_http_error(exc.GitlabRotateError)
666+
def rotate(
667+
self, id: Union[str, int], expires_at: Optional[str] = None, **kwargs: Any
668+
) -> Dict[str, Any]:
669+
"""Rotate an access token.
670+
671+
Args:
672+
id: ID of the token to rotate
673+
**kwargs: Extra options to send to the server (e.g. sudo)
674+
675+
Raises:
676+
GitlabAuthenticationError: If authentication is not correct
677+
GitlabRotateError: If the server cannot perform the request
678+
"""
679+
path = f"{self.path}/{utils.EncodedId(id)}/rotate"
680+
data: Dict[str, Any] = {}
681+
if expires_at is not None:
682+
data = {"expires_at": expires_at}
683+
684+
server_data = self.gitlab.http_post(path, post_data=data, **kwargs)
685+
if TYPE_CHECKING:
686+
assert not isinstance(server_data, requests.Response)
687+
return server_data
688+
689+
690+
class ObjectRotateMixin(_RestObjectBase):
691+
_id_attr: Optional[str]
692+
_attrs: Dict[str, Any]
693+
_module: ModuleType
694+
_parent_attrs: Dict[str, Any]
695+
_updated_attrs: Dict[str, Any]
696+
manager: base.RESTManager
697+
698+
def rotate(self, **kwargs: Any) -> None:
699+
"""Rotate the current access token object.
700+
701+
Args:
702+
**kwargs: Extra options to send to the server (e.g. sudo)
703+
704+
Raises:
705+
GitlabAuthenticationError: If authentication is not correct
706+
GitlabRotateError: If the server cannot perform the request
707+
"""
708+
if TYPE_CHECKING:
709+
assert isinstance(self.manager, RotateMixin)
710+
assert self.encoded_id is not None
711+
server_data = self.manager.rotate(self.encoded_id, **kwargs)
712+
self._update_attrs(server_data)
713+
714+
656715
class SubscribableMixin(_RestObjectBase):
657716
_id_attr: Optional[str]
658717
_attrs: Dict[str, Any]

gitlab/v4/objects/group_access_tokens.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from typing import Any, cast, Union
22

33
from gitlab.base import RESTManager, RESTObject
4-
from gitlab.mixins import CreateMixin, DeleteMixin, ObjectDeleteMixin, RetrieveMixin
4+
from gitlab.mixins import (
5+
CreateMixin,
6+
DeleteMixin,
7+
ObjectDeleteMixin,
8+
ObjectRotateMixin,
9+
RetrieveMixin,
10+
RotateMixin,
11+
)
512
from gitlab.types import ArrayAttribute, RequiredOptional
613

714
__all__ = [
@@ -10,11 +17,13 @@
1017
]
1118

1219

13-
class GroupAccessToken(ObjectDeleteMixin, RESTObject):
20+
class GroupAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
1421
pass
1522

1623

17-
class GroupAccessTokenManager(CreateMixin, DeleteMixin, RetrieveMixin, RESTManager):
24+
class GroupAccessTokenManager(
25+
CreateMixin, DeleteMixin, RetrieveMixin, RotateMixin, RESTManager
26+
):
1827
_path = "/groups/{group_id}/access_tokens"
1928
_obj_cls = GroupAccessToken
2029
_from_parent_attrs = {"group_id": "id"}

gitlab/v4/objects/personal_access_tokens.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from typing import Any, cast, Union
22

33
from gitlab.base import RESTManager, RESTObject
4-
from gitlab.mixins import CreateMixin, DeleteMixin, ObjectDeleteMixin, RetrieveMixin
4+
from gitlab.mixins import (
5+
CreateMixin,
6+
DeleteMixin,
7+
ObjectDeleteMixin,
8+
ObjectRotateMixin,
9+
RetrieveMixin,
10+
RotateMixin,
11+
)
512
from gitlab.types import ArrayAttribute, RequiredOptional
613

714
__all__ = [
@@ -12,11 +19,11 @@
1219
]
1320

1421

15-
class PersonalAccessToken(ObjectDeleteMixin, RESTObject):
22+
class PersonalAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
1623
pass
1724

1825

19-
class PersonalAccessTokenManager(DeleteMixin, RetrieveMixin, RESTManager):
26+
class PersonalAccessTokenManager(DeleteMixin, RetrieveMixin, RotateMixin, RESTManager):
2027
_path = "/personal_access_tokens"
2128
_obj_cls = PersonalAccessToken
2229
_list_filters = ("user_id",)

gitlab/v4/objects/project_access_tokens.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from typing import Any, cast, Union
22

33
from gitlab.base import RESTManager, RESTObject
4-
from gitlab.mixins import CreateMixin, DeleteMixin, ObjectDeleteMixin, RetrieveMixin
4+
from gitlab.mixins import (
5+
CreateMixin,
6+
DeleteMixin,
7+
ObjectDeleteMixin,
8+
ObjectRotateMixin,
9+
RetrieveMixin,
10+
RotateMixin,
11+
)
512
from gitlab.types import ArrayAttribute, RequiredOptional
613

714
__all__ = [
@@ -10,11 +17,13 @@
1017
]
1118

1219

13-
class ProjectAccessToken(ObjectDeleteMixin, RESTObject):
20+
class ProjectAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject):
1421
pass
1522

1623

17-
class ProjectAccessTokenManager(CreateMixin, DeleteMixin, RetrieveMixin, RESTManager):
24+
class ProjectAccessTokenManager(
25+
CreateMixin, DeleteMixin, RetrieveMixin, RotateMixin, RESTManager
26+
):
1827
_path = "/projects/{project_id}/access_tokens"
1928
_obj_cls = ProjectAccessToken
2029
_from_parent_attrs = {"project_id": "id"}

tests/unit/objects/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def token_content():
3232
"active": True,
3333
"created_at": "2021-01-20T22:11:48.151Z",
3434
"revoked": False,
35+
"token": "s3cr3t",
3536
}
3637

3738

tests/unit/objects/test_group_access_tokens.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,12 @@ def resp_get_group_access_token(token_content):
3535

3636

3737
@pytest.fixture
38-
def resp_create_group_access_token():
39-
content = {
40-
"user_id": 141,
41-
"scopes": ["api"],
42-
"name": "token",
43-
"expires_at": "2021-01-31",
44-
"id": 42,
45-
"active": True,
46-
"created_at": "2021-01-20T22:11:48.151Z",
47-
"revoked": False,
48-
}
49-
38+
def resp_create_group_access_token(token_content):
5039
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
5140
rsps.add(
5241
method=responses.POST,
5342
url="http://localhost/api/v4/groups/1/access_tokens",
54-
json=content,
43+
json=token_content,
5544
content_type="application/json",
5645
status=200,
5746
)
@@ -89,6 +78,19 @@ def resp_revoke_group_access_token():
8978
yield rsps
9079

9180

81+
@pytest.fixture
82+
def resp_rotate_group_access_token(token_content):
83+
with responses.RequestsMock() as rsps:
84+
rsps.add(
85+
method=responses.POST,
86+
url="http://localhost/api/v4/groups/1/access_tokens/1/rotate",
87+
json=token_content,
88+
content_type="application/json",
89+
status=200,
90+
)
91+
yield rsps
92+
93+
9294
def test_list_group_access_tokens(gl, resp_list_group_access_token):
9395
access_tokens = gl.groups.get(1, lazy=True).access_tokens.list()
9496
assert len(access_tokens) == 1
@@ -118,3 +120,10 @@ def test_revoke_group_access_token(
118120
gl.groups.get(1, lazy=True).access_tokens.delete(42)
119121
access_token = gl.groups.get(1, lazy=True).access_tokens.list()[0]
120122
access_token.delete()
123+
124+
125+
def test_rotate_group_access_token(group, resp_rotate_group_access_token):
126+
access_token = group.access_tokens.get(1, lazy=True)
127+
access_token.rotate()
128+
assert isinstance(access_token, GroupAccessToken)
129+
assert access_token.token == "s3cr3t"

tests/unit/objects/test_personal_access_tokens.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import pytest
88
import responses
99

10+
from gitlab.v4.objects import PersonalAccessToken
11+
1012
user_id = 1
1113
token_id = 1
1214
token_name = "Test Token"
@@ -91,6 +93,19 @@ def resp_delete_personal_access_token():
9193
yield rsps
9294

9395

96+
@pytest.fixture
97+
def resp_rotate_personal_access_token(token_content):
98+
with responses.RequestsMock() as rsps:
99+
rsps.add(
100+
method=responses.POST,
101+
url="http://localhost/api/v4/personal_access_tokens/1/rotate",
102+
json=token_content,
103+
content_type="application/json",
104+
status=200,
105+
)
106+
yield rsps
107+
108+
94109
def test_create_personal_access_token(gl, resp_create_user_personal_access_token):
95110
user = gl.users.get(1, lazy=True)
96111
access_token = user.personal_access_tokens.create(
@@ -135,3 +150,10 @@ def test_delete_personal_access_token(gl, resp_delete_personal_access_token):
135150

136151
def test_revoke_personal_access_token_by_id(gl, resp_delete_personal_access_token):
137152
gl.personal_access_tokens.delete(token_id)
153+
154+
155+
def test_rotate_project_access_token(gl, resp_rotate_personal_access_token):
156+
access_token = gl.personal_access_tokens.get(1, lazy=True)
157+
access_token.rotate()
158+
assert isinstance(access_token, PersonalAccessToken)
159+
assert access_token.token == "s3cr3t"

tests/unit/objects/test_project_access_tokens.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,12 @@ def resp_get_project_access_token(token_content):
3535

3636

3737
@pytest.fixture
38-
def resp_create_project_access_token():
39-
content = {
40-
"user_id": 141,
41-
"scopes": ["api"],
42-
"name": "token",
43-
"expires_at": "2021-01-31",
44-
"id": 42,
45-
"active": True,
46-
"created_at": "2021-01-20T22:11:48.151Z",
47-
"revoked": False,
48-
}
49-
38+
def resp_create_project_access_token(token_content):
5039
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
5140
rsps.add(
5241
method=responses.POST,
5342
url="http://localhost/api/v4/projects/1/access_tokens",
54-
json=content,
43+
json=token_content,
5544
content_type="application/json",
5645
status=200,
5746
)
@@ -89,6 +78,19 @@ def resp_revoke_project_access_token():
8978
yield rsps
9079

9180

81+
@pytest.fixture
82+
def resp_rotate_project_access_token(token_content):
83+
with responses.RequestsMock() as rsps:
84+
rsps.add(
85+
method=responses.POST,
86+
url="http://localhost/api/v4/projects/1/access_tokens/1/rotate",
87+
json=token_content,
88+
content_type="application/json",
89+
status=200,
90+
)
91+
yield rsps
92+
93+
9294
def test_list_project_access_tokens(gl, resp_list_project_access_token):
9395
access_tokens = gl.projects.get(1, lazy=True).access_tokens.list()
9496
assert len(access_tokens) == 1
@@ -118,3 +120,10 @@ def test_revoke_project_access_token(
118120
gl.projects.get(1, lazy=True).access_tokens.delete(42)
119121
access_token = gl.projects.get(1, lazy=True).access_tokens.list()[0]
120122
access_token.delete()
123+
124+
125+
def test_rotate_project_access_token(project, resp_rotate_project_access_token):
126+
access_token = project.access_tokens.get(1, lazy=True)
127+
access_token.rotate()
128+
assert isinstance(access_token, ProjectAccessToken)
129+
assert access_token.token == "s3cr3t"

0 commit comments

Comments
 (0)