1#

2# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com>

3#

4# This program is free software: you can redistribute it and/or modify

5# it under the terms of the GNU Lesser General Public License as published by

6# the Free Software Foundation, either version 3 of the License, or

7# (at your option) any later version.

8#

9# This program is distributed in the hope that it will be useful,

10# but WITHOUT ANY WARRANTY; without even the implied warranty of

11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

12# GNU Lesser General Public License for more details.

13#

14# You should have received a copy of the GNU Lesser General Public License

15# along with this program. If not, see <http://www.gnu.org/licenses/>.

16

17 import logging

18 from typing import ClassVar

19

20 from gitlabracadabra.objects.object import GitLabracadabraObject

21

22 logger = logging.getLogger(__name__)

23

24

25 class GitLabracadabraUser(GitLabracadabraObject):

26 EXAMPLE_YAML_HEADER: ClassVar[str] = "mmyuser:\n type: user\n"

27 DOC: ClassVar[list[str]] = [

28 "# User lifecycle",

29 "gitlab_id",

30 "create_object",

31 "delete_object",

32 "# Edit",

33 "## Account",

34 "name",

35 # 'username',

36 "email",

37 "skip_confirmation",

38 "skip_reconfirmation",

39 "public_email",

40 "state",

41 "## Password",

42 "password",

43 "reset_password",

44 "force_random_password",

45 "## Access",

46 "projects_limit",

47 "can_create_group",

48 "admin",

49 "external",

50 "provider",

51 "extern_uid",

52 "## Limits",

53 "shared_runners_minutes_limit",

54 "extra_shared_runners_minutes_limit",

55 "## Profile",

56 "avatar",

57 "skype",

58 "linkedin",

59 "twitter",

60 "website_url",

61 "location",

62 "organization",

63 "bio",

64 "private_profile",

65 "note",

66 ]

67 SCHEMA: ClassVar[dict] = {

68 "$schema": "http://json-schema.org/draft-04/schema#",

69 "title": "User",

70 "type": "object",

71 "properties": {

72 # Standard properties

73 "gitlab_id": {

74 "type": "string",

75 "description": "GitLab id",

76 "_example": "gitlab",

77 "_doc_link": "action_file.md#gitlab_id",

78 },

79 "create_object": {

80 "type": "boolean",

81 "description": "Create object if it does not exists",

82 },

83 "delete_object": {

84 "type": "boolean",

85 "description": "Delete object if it exists",

86 },

87 # From https://docs.gitlab.com/ee/api/users.html#user-creation

88 # 'username': {

89 # 'type': 'string',

90 # 'description': 'Username',

91 # },

92 "name": {

93 "type": "string",

94 "description": "Name",

95 },

96 "email": {

97 "type": "string",

98 "description": "Email",

99 },

100 "skip_confirmation": {

101 "type": "boolean",

102 "description": "Skip confirmation and assume e-mail is verified",

103 },

104 "skip_reconfirmation": {

105 "type": "boolean",

106 "description": "Skip reconfirmation",

107 },

108 "public_email": {

109 "type": "string",

110 "description": "The public email of the user",

111 },

112 "state": {

113 "type": "string",

114 "description": "User state",

115 "enum": [

116 "active",

117 "banned",

118 "blocked",

119 "blocked_pending_approval",

120 "deactivated",

121 "ldap_blocked",

122 ],

123 },

124 "password": {

125 "type": "string",

126 "description": "Password",

127 },

128 "reset_password": {

129 "type": "boolean",

130 "description": "Send user password reset link",

131 },

132 "force_random_password": {

133 "type": "boolean",

134 "description": "Set user password to a random value ",

135 },

136 "projects_limit": {

137 "type": "integer",

138 "description": "Number of projects user can create",

139 "multipleOf": 1,

140 "minimum": 0,

141 },

142 "can_create_group": {

143 "type": "boolean",

144 "description": "User can create groups",

145 },

146 "admin": {

147 "type": "boolean",

148 "description": "User is admin",

149 },

150 "external": {

151 "type": "boolean",

152 "description": "Flags the user as external",

153 },

154 "provider": {

155 "type": "string",

156 "description": "External provider name",

157 },

158 "extern_uid": {

159 "type": "string",

160 "description": "External UID",

161 },

162 "shared_runners_minutes_limit": {

163 "type": "integer",

164 "description": "Pipeline minutes quota for this user",

165 "multipleOf": 1,

166 "minimum": 0,

167 },

168 "extra_shared_runners_minutes_limit": {

169 "type": "integer",

170 "description": "Extra pipeline minutes quota for this user",

171 "multipleOf": 1,

172 "minimum": 0,

173 },

174 "avatar": {

175 "type": "string",

176 "description": "Image file for user's avatar",

177 },

178 "skype": {

179 "type": "string",

180 "description": "Skype ID",

181 },

182 "linkedin": {

183 "type": "string",

184 "description": "LinkedIn",

185 },

186 "twitter": {

187 "type": "string",

188 "description": "Twitter account",

189 },

190 "website_url": {

191 "type": "string",

192 "description": "Website URL",

193 },

194 "location": {

195 "type": "string",

196 "description": "User's location",

197 },

198 "organization": {

199 "type": "string",

200 "description": "Organization name",

201 },

202 "bio": {

203 "type": "string",

204 "description": "User's biography",

205 },

206 "private_profile": {

207 "type": "boolean",

208 "description": "User's profile is private",

209 },

210 "note": {

211 "type": "string",

212 "description": "Admin note",

213 },

214 },

215 "additionalProperties": False,

216 "dependencies": {

217 "email": ["skip_reconfirmation"],

218 },

219 }

220

221 FIND_PARAM = "username"

222

223 CREATE_KEY = "username"

224

225 CREATE_PARAMS: ClassVar[list[str]] = [

226 "email",

227 "password",

228 "force_random_password",

229 "reset_password",

230 "skip_confirmation",

231 "name",

232 ]

233

234 IGNORED_PARAMS: ClassVar[list[str]] = [

235 "password",

236 "force_random_password",

237 "reset_password",

238 "skip_confirmation",

239 "skip_reconfirmation",

240 ]

241

242 """"_get_param()

243

244 Get a param value.

245 """

246

247 def _get_param(self, param_name):

248 if param_name == "admin":

249 param_name = "is_admin"

250 return super()._get_param(param_name)

251

252 """"_process_state()

253

254 Process the state param.

255 """

256

257 def _process_state(self, param_name, param_value, *, dry_run=False, skip_save=False):

258 assert param_name == "state" # noqa: S101

259 assert not skip_save # noqa: S101

260

261 current_value = getattr(self._obj, param_name)

262 if current_value != param_value: 262 ↛ exit line 262 didn't return from function '_process_state' because the condition on line 262 was always true

263 # From Gitlab's state machine

264 # https://gitlab.com/gitlab-org/gitlab/-/blob/8976bab138344e55e7feb1725cf63770d0a2741b/app/models/user.rb#L324-367

265 action = self._state_action(current_value, param_value)

266 if action is None: 266 ↛ 267 line 266 didn't jump to line 267 because the condition on line 266 was never true

267 logger.warning(

268 "[%s] No action found to change param %s: %s -> %s (dry-run)",

269 self._name,

270 param_name,

271 current_value,

272 param_value,

273 )

274 elif dry_run: 274 ↛ 275 line 274 didn't jump to line 275 because the condition on line 274 was never true

275 logger.info(

276 "[%s] NOT doing %s to change param %s: %s -> %s (dry-run)",

277 self._name,

278 action,

279 param_name,

280 current_value,

281 param_value,

282 )

283 else:

284 logger.info(

285 "[%s] Doing %s to change param %s: %s -> %s (dry-run)",

286 self._name,

287 action,

288 param_name,

289 current_value,

290 param_value,

291 )

292 getattr(self._obj, action)()

293

294 """"_state_action()

295

296 Get action.

297 """

298

299 def _state_action(self, current: str, target: str) -> str | None:

300 # https://gitlab.com/gitlab-org/gitlab/-/blob/1856858760a831a568d6ddae912ed1fc141d76cd/app/models/user.rb#L356-433

301 # and https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/api/users.rb

302 # (current, target): action

303 transitions = {

304 ("active", "blocked"): "block",

305 ("deactivated", "blocked"): "block",

306 ("ldap_blocked", "blocked"): "block",

307 ("blocked_pending_approval", "blocked"): "block",

308 ("blocked", "active"): "unblock",

309 ("blocked_pending_approval", "active"): "approve",

310 ("deactivated", "active"): "activate",

311 ("active", "banned"): "ban",

312 ("banned", "active"): "unban",

313 ("active", "deactivated"): "deactivate",

314 }

315 return transitions.get((current, target), None)