slack-objects 0.0.post31__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slack_objects/__init__.py +22 -0
- slack_objects/_version.py +34 -0
- slack_objects/api_caller.py +42 -0
- slack_objects/base.py +30 -0
- slack_objects/client.py +50 -0
- slack_objects/config.py +29 -0
- slack_objects/conversations.py +437 -0
- slack_objects/files.py +331 -0
- slack_objects/idp_groups.py +200 -0
- slack_objects/messages.py +322 -0
- slack_objects/rate_limits.py +51 -0
- slack_objects/users.py +554 -0
- slack_objects/workspaces.py +261 -0
- slack_objects-0.0.post31.dist-info/METADATA +201 -0
- slack_objects-0.0.post31.dist-info/RECORD +18 -0
- slack_objects-0.0.post31.dist-info/WHEEL +5 -0
- slack_objects-0.0.post31.dist-info/licenses/LICENSE +21 -0
- slack_objects-0.0.post31.dist-info/top_level.txt +1 -0
slack_objects/users.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
slack_objects.users
|
|
5
|
+
==================
|
|
6
|
+
|
|
7
|
+
Users helper for the `slack-objects` package.
|
|
8
|
+
|
|
9
|
+
Goals:
|
|
10
|
+
- Factory-friendly: `users = slack.users()` or `users = slack.users("U123")`
|
|
11
|
+
- Modular internals: public methods call *wrapper methods*; wrapper methods are the only place
|
|
12
|
+
that directly call Slack Web/Admin APIs (via SlackApiCaller) or SCIM (via requests).
|
|
13
|
+
- Testable: wrapper methods are easy to fake/mocking; SCIM uses an injectable requests.Session.
|
|
14
|
+
|
|
15
|
+
This module intentionally covers only user-related operations and helpers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Dict, Optional, Sequence, Union, List
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .base import SlackObjectBase
|
|
26
|
+
from .config import RateTier
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScimResponse:
|
|
31
|
+
"""
|
|
32
|
+
Simple structured response for SCIM requests.
|
|
33
|
+
|
|
34
|
+
SCIM responses are not Slack Web API responses (no `ok` boolean), so returning a consistent
|
|
35
|
+
wrapper makes scripts/tests easier to write.
|
|
36
|
+
"""
|
|
37
|
+
ok: bool
|
|
38
|
+
status_code: int
|
|
39
|
+
data: Dict[str, Any]
|
|
40
|
+
text: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Users(SlackObjectBase):
|
|
45
|
+
"""
|
|
46
|
+
Users domain helper.
|
|
47
|
+
|
|
48
|
+
Factory-style usage:
|
|
49
|
+
slack = SlackObjectsClient(cfg)
|
|
50
|
+
users = slack.users() # unbound
|
|
51
|
+
alice = slack.users("U123") # bound to user_id
|
|
52
|
+
|
|
53
|
+
Notes:
|
|
54
|
+
- `user_id` is optional. If you call methods that need a user, they will require a bound
|
|
55
|
+
user_id or a passed user_id.
|
|
56
|
+
- `attributes` are cached after `refresh()`; many helpers read from this cache.
|
|
57
|
+
"""
|
|
58
|
+
user_id: Optional[str] = None
|
|
59
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
# Heuristic label used historically to identify contingent workers
|
|
62
|
+
cw_label: str = "[External]"
|
|
63
|
+
|
|
64
|
+
# Optional requests session (handy for unit tests and connection pooling)
|
|
65
|
+
scim_session: requests.Session = field(default_factory=requests.Session, repr=False)
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
super().__post_init__()
|
|
69
|
+
|
|
70
|
+
# Eagerly load attributes if we have a user_id
|
|
71
|
+
if self.user_id:
|
|
72
|
+
# will raise RuntimeError if users.info fails
|
|
73
|
+
self.refresh()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------- factory helpers ----------
|
|
77
|
+
|
|
78
|
+
def with_user(self, user_id: str) -> "Users":
|
|
79
|
+
"""
|
|
80
|
+
Return a new Users instance bound to user_id, sharing cfg/client/logger/api.
|
|
81
|
+
"""
|
|
82
|
+
return Users(
|
|
83
|
+
cfg=self.cfg,
|
|
84
|
+
client=self.client,
|
|
85
|
+
logger=self.logger,
|
|
86
|
+
api=self.api,
|
|
87
|
+
user_id=user_id,
|
|
88
|
+
scim_session=self.scim_session,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ---------- attribute lifecycle ----------
|
|
92
|
+
|
|
93
|
+
def refresh(self, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
94
|
+
"""
|
|
95
|
+
Refresh attributes for user_id (or self.user_id) using users.info.
|
|
96
|
+
|
|
97
|
+
This method is intentionally layered: it calls `get_user_info()`, which calls the
|
|
98
|
+
underlying endpoint wrapper.
|
|
99
|
+
"""
|
|
100
|
+
if user_id:
|
|
101
|
+
self.user_id = user_id
|
|
102
|
+
if not self.user_id:
|
|
103
|
+
raise ValueError("refresh() requires user_id (passed or already set)")
|
|
104
|
+
|
|
105
|
+
resp = self.get_user_info(self.user_id)
|
|
106
|
+
if not resp.get("ok"):
|
|
107
|
+
raise RuntimeError(f"Users.get_user_info() failed: {resp}")
|
|
108
|
+
|
|
109
|
+
self.attributes = resp.get("user") or {}
|
|
110
|
+
return self.attributes
|
|
111
|
+
|
|
112
|
+
def _require_attributes(self) -> Dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Ensure attributes are loaded (via refresh) before using helpers that rely on profile fields.
|
|
115
|
+
"""
|
|
116
|
+
if self.attributes:
|
|
117
|
+
return self.attributes
|
|
118
|
+
if self.user_id:
|
|
119
|
+
return self.refresh()
|
|
120
|
+
raise ValueError("User attributes not loaded and no user_id set (call refresh() or bind a user_id).")
|
|
121
|
+
|
|
122
|
+
# ============================================================
|
|
123
|
+
# Slack Web/Admin API wrapper layer
|
|
124
|
+
# ============================================================
|
|
125
|
+
# Only these methods should call `self.api.call(...)` directly.
|
|
126
|
+
# Everything else should call these wrappers.
|
|
127
|
+
|
|
128
|
+
def _users_info(self, user_id: str) -> Dict[str, Any]:
|
|
129
|
+
"""Wrapper for users.info."""
|
|
130
|
+
return self.api.call(self.client, "users.info", rate_tier=RateTier.TIER_4, user=user_id)
|
|
131
|
+
|
|
132
|
+
def _users_lookup_by_email(self, email: str) -> Dict[str, Any]:
|
|
133
|
+
"""Wrapper for users.lookupByEmail."""
|
|
134
|
+
return self.api.call(self.client, "users.lookupByEmail", rate_tier=RateTier.TIER_3, email=email)
|
|
135
|
+
|
|
136
|
+
def _users_profile_get(self, user_id: str) -> Dict[str, Any]:
|
|
137
|
+
"""Wrapper for users.profile.get."""
|
|
138
|
+
return self.api.call(self.client, "users.profile.get", rate_tier=RateTier.TIER_4, user=user_id)
|
|
139
|
+
|
|
140
|
+
def _users_profile_set_name_value(self, user_id: str, field_id: str, new_value: str) -> Dict[str, Any]:
|
|
141
|
+
"""
|
|
142
|
+
Wrapper for users.profile.set using legacy name/value style.
|
|
143
|
+
|
|
144
|
+
Note: Slack's recommended pattern can also be "profile": {...}. If you switch later,
|
|
145
|
+
you only need to update this wrapper.
|
|
146
|
+
"""
|
|
147
|
+
return self.api.call(
|
|
148
|
+
self.client,
|
|
149
|
+
"users.profile.set",
|
|
150
|
+
rate_tier=RateTier.TIER_3,
|
|
151
|
+
user=user_id,
|
|
152
|
+
name=field_id,
|
|
153
|
+
value=new_value,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _admin_users_invite(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
157
|
+
"""Wrapper for admin.users.invite."""
|
|
158
|
+
return self.api.call(self.client, "admin.users.invite", rate_tier=RateTier.TIER_2, **payload)
|
|
159
|
+
|
|
160
|
+
def _admin_users_session_reset(self, user_id: str) -> Dict[str, Any]:
|
|
161
|
+
"""Wrapper for admin.users.session.reset."""
|
|
162
|
+
return self.api.call(self.client, "admin.users.session.reset", rate_tier=RateTier.TIER_2, user_id=user_id)
|
|
163
|
+
|
|
164
|
+
def _admin_users_assign(self, user_id: str, team_id: str) -> Dict[str, Any]:
|
|
165
|
+
"""Wrapper for admin.users.assign."""
|
|
166
|
+
return self.api.call(self.client, "admin.users.assign", rate_tier=RateTier.TIER_2, user_id=user_id, team_id=team_id)
|
|
167
|
+
|
|
168
|
+
def _admin_users_remove(self, user_id: str, team_id: str) -> Dict[str, Any]:
|
|
169
|
+
"""Wrapper for admin.users.remove."""
|
|
170
|
+
return self.api.call(self.client, "admin.users.remove", rate_tier=RateTier.TIER_2, user_id=user_id, team_id=team_id)
|
|
171
|
+
|
|
172
|
+
def _admin_conversations_invite(self, user_ids: Sequence[str], channel_id: str) -> Dict[str, Any]:
|
|
173
|
+
"""Wrapper for admin.conversations.invite."""
|
|
174
|
+
return self.api.call(
|
|
175
|
+
self.client,
|
|
176
|
+
"admin.conversations.invite",
|
|
177
|
+
rate_tier=RateTier.TIER_2,
|
|
178
|
+
user_ids=list(user_ids),
|
|
179
|
+
channel_id=channel_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _conversations_kick(self, user_id: str, channel_id: str) -> Dict[str, Any]:
|
|
183
|
+
"""Wrapper for conversations.kick."""
|
|
184
|
+
return self.api.call(self.client, "conversations.kick", rate_tier=RateTier.TIER_3, user=user_id, channel=channel_id)
|
|
185
|
+
|
|
186
|
+
def _admin_users_set_expiration(self, *, user_id: str, expiration_ts: int, workspace_id: str = "") -> Dict[str, Any]:
|
|
187
|
+
"""Wrapper for admin.users.setExpiration."""
|
|
188
|
+
payload: Dict[str, Any] = {
|
|
189
|
+
"expiration_ts": expiration_ts,
|
|
190
|
+
"user_id": user_id,
|
|
191
|
+
}
|
|
192
|
+
if workspace_id:
|
|
193
|
+
payload["team_id"] = workspace_id
|
|
194
|
+
|
|
195
|
+
return self.api.call(
|
|
196
|
+
self.client,
|
|
197
|
+
"admin.users.setExpiration",
|
|
198
|
+
rate_tier=RateTier.TIER_2,
|
|
199
|
+
**payload,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _discovery_user_conversations(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
203
|
+
"""Wrapper for discovery.user.conversations."""
|
|
204
|
+
return self.api.call(self.client, "discovery.user.conversations", rate_tier=RateTier.TIER_3, **payload)
|
|
205
|
+
|
|
206
|
+
# ============================================================
|
|
207
|
+
# Public Slack Web/Admin methods (call wrappers above)
|
|
208
|
+
# ============================================================
|
|
209
|
+
|
|
210
|
+
def get_user_info(self, user_id: str) -> Dict[str, Any]:
|
|
211
|
+
"""Public method for users.info (calls wrapper)."""
|
|
212
|
+
return self._users_info(user_id)
|
|
213
|
+
|
|
214
|
+
def lookup_by_email(self, email: str) -> Dict[str, Any]:
|
|
215
|
+
"""Public method for users.lookupByEmail (calls wrapper)."""
|
|
216
|
+
return self._users_lookup_by_email(email)
|
|
217
|
+
|
|
218
|
+
def get_user_id_from_email(self, email: str) -> str:
|
|
219
|
+
"""
|
|
220
|
+
Convenience wrapper that returns only the Slack user ID for an email.
|
|
221
|
+
|
|
222
|
+
Legacy behavior returned '' on miss; keep that for compatibility.
|
|
223
|
+
"""
|
|
224
|
+
resp = self.lookup_by_email(email)
|
|
225
|
+
if resp.get("ok"):
|
|
226
|
+
return (resp.get("user") or {}).get("id", "") or ""
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
def get_user_profile(self, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
230
|
+
"""Fetch user profile (users.profile.get)."""
|
|
231
|
+
uid = user_id or self.user_id
|
|
232
|
+
if not uid:
|
|
233
|
+
raise ValueError("get_user_profile requires user_id (passed or bound)")
|
|
234
|
+
return self._users_profile_get(uid)
|
|
235
|
+
|
|
236
|
+
def set_user_profile_field(self, field_id: str, new_value: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
237
|
+
"""Update a single profile field using the legacy name/value style (users.profile.set)."""
|
|
238
|
+
uid = user_id or self.user_id
|
|
239
|
+
if not uid:
|
|
240
|
+
raise ValueError("set_user_profile_field requires user_id (passed or bound)")
|
|
241
|
+
return self._users_profile_set_name_value(uid, field_id, new_value)
|
|
242
|
+
|
|
243
|
+
# ---------- classification helpers ----------
|
|
244
|
+
|
|
245
|
+
def is_contingent_worker(self) -> bool:
|
|
246
|
+
"""Return True if the user's name/display_name contains the CW label."""
|
|
247
|
+
attrs = self._require_attributes()
|
|
248
|
+
real_name = str(attrs.get("real_name", ""))
|
|
249
|
+
display_name = str((attrs.get("profile") or {}).get("display_name", ""))
|
|
250
|
+
return (self.cw_label in real_name) or (self.cw_label in display_name)
|
|
251
|
+
|
|
252
|
+
def is_guest(self) -> bool:
|
|
253
|
+
"""Return True for restricted or ultra-restricted guest accounts."""
|
|
254
|
+
attrs = self._require_attributes()
|
|
255
|
+
return bool(attrs.get("is_restricted") or attrs.get("is_ultra_restricted"))
|
|
256
|
+
|
|
257
|
+
# ---------- auth helpers ----------
|
|
258
|
+
|
|
259
|
+
def is_user_authorized(self, service_name: str, auth_level: str = "read") -> bool:
|
|
260
|
+
"""
|
|
261
|
+
Determine whether the bound user is authorized for a service.
|
|
262
|
+
|
|
263
|
+
Authorization is based on IdP group membership, using policy defined in cfg.
|
|
264
|
+
|
|
265
|
+
Expected config shape:
|
|
266
|
+
cfg.auth_idp_groups_read_access: dict[str, list[str]]
|
|
267
|
+
cfg.auth_idp_groups_write_access: dict[str, list[str]]
|
|
268
|
+
|
|
269
|
+
This method intentionally delegates all membership checks to IDP_groups.
|
|
270
|
+
"""
|
|
271
|
+
if not self.user_id:
|
|
272
|
+
raise ValueError("is_user_authorized requires a bound user_id")
|
|
273
|
+
|
|
274
|
+
# Resolve policy
|
|
275
|
+
if auth_level == "write":
|
|
276
|
+
group_ids = getattr(self.cfg, "auth_idp_groups_write_access", {}).get(service_name, [])
|
|
277
|
+
else:
|
|
278
|
+
group_ids = getattr(self.cfg, "auth_idp_groups_read_access", {}).get(service_name, [])
|
|
279
|
+
|
|
280
|
+
if not group_ids:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
# Lazy import to avoid circular dependencies
|
|
284
|
+
from .idp_groups import IDP_groups
|
|
285
|
+
|
|
286
|
+
idp = IDP_groups(
|
|
287
|
+
cfg=self.cfg,
|
|
288
|
+
client=self.client,
|
|
289
|
+
logger=self.logger,
|
|
290
|
+
api=self.api,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
for group_id in group_ids:
|
|
294
|
+
if idp.is_member(user_id=self.user_id, group_id=group_id):
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------- admin api helpers ----------
|
|
301
|
+
|
|
302
|
+
def invite_user(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
channel_ids: Union[str, Sequence[str]],
|
|
306
|
+
email: str,
|
|
307
|
+
team_id: str,
|
|
308
|
+
email_password_policy_enabled: bool = False,
|
|
309
|
+
) -> Dict[str, Any]:
|
|
310
|
+
"""
|
|
311
|
+
admin.users.invite
|
|
312
|
+
|
|
313
|
+
Accepts either "C1,C2" or ["C1", "C2"].
|
|
314
|
+
|
|
315
|
+
If cfg.user_token exists, we pass it explicitly in the payload (matches your legacy intent).
|
|
316
|
+
"""
|
|
317
|
+
channel_ids_str = ",".join(channel_ids) if isinstance(channel_ids, (list, tuple, set)) else channel_ids
|
|
318
|
+
|
|
319
|
+
payload: Dict[str, Any] = {
|
|
320
|
+
"channel_ids": channel_ids_str,
|
|
321
|
+
"email": email,
|
|
322
|
+
"team_id": team_id,
|
|
323
|
+
"email_password_policy_enabled": email_password_policy_enabled,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if getattr(self.cfg, "user_token", None):
|
|
327
|
+
payload["token"] = self.cfg.user_token
|
|
328
|
+
|
|
329
|
+
return self._admin_users_invite(payload)
|
|
330
|
+
|
|
331
|
+
def wipe_all_sessions(self, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
332
|
+
"""admin.users.session.reset"""
|
|
333
|
+
uid = user_id or self.user_id
|
|
334
|
+
if not uid:
|
|
335
|
+
raise ValueError("wipe_all_sessions requires user_id (passed or bound)")
|
|
336
|
+
return self._admin_users_session_reset(uid)
|
|
337
|
+
|
|
338
|
+
def add_to_workspace(self, user_id: str, workspace_id: str) -> Dict[str, Any]:
|
|
339
|
+
"""admin.users.assign"""
|
|
340
|
+
return self._admin_users_assign(user_id=user_id, team_id=workspace_id)
|
|
341
|
+
|
|
342
|
+
def remove_from_workspace(self, user_id: str, workspace_id: str) -> Dict[str, Any]:
|
|
343
|
+
"""admin.users.remove"""
|
|
344
|
+
return self._admin_users_remove(user_id=user_id, team_id=workspace_id)
|
|
345
|
+
|
|
346
|
+
def add_to_conversation(self, user_ids: Sequence[str], channel_id: str) -> Dict[str, Any]:
|
|
347
|
+
"""admin.conversations.invite"""
|
|
348
|
+
return self._admin_conversations_invite(user_ids=user_ids, channel_id=channel_id)
|
|
349
|
+
|
|
350
|
+
def remove_from_conversation(self, user_id: str, channel_id: str) -> Dict[str, Any]:
|
|
351
|
+
"""conversations.kick"""
|
|
352
|
+
return self._conversations_kick(user_id=user_id, channel_id=channel_id)
|
|
353
|
+
|
|
354
|
+
def set_guest_expiration_date(self, expiration_date: str, user_id: Optional[str] = None, workspace_id: str = "") -> Dict[str, Any]:
|
|
355
|
+
"""
|
|
356
|
+
Set the expiration date for a guest user (admin.users.setExpiration).
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
expiration_date: A date string accepted by PC_Utils.Datetime.Datetime.date_to_epoch(). Default is %Y-%m-%d
|
|
360
|
+
user_id: Optional override; if omitted, uses the bound self.user_id
|
|
361
|
+
workspace_id: Optional team/workspace ID for multi-workspace orgs
|
|
362
|
+
"""
|
|
363
|
+
uid = user_id or self.user_id
|
|
364
|
+
if not uid:
|
|
365
|
+
raise ValueError("set_guest_expiration_date requires user_id (passed or bound)")
|
|
366
|
+
|
|
367
|
+
# Lazy import: keeps slack-objects importable even if PC_Utils isn't installed,
|
|
368
|
+
# as long as this method isn't called.
|
|
369
|
+
from PC_Utils.Datetime import Datetime
|
|
370
|
+
|
|
371
|
+
expiration_ts = Datetime.date_to_epoch(expiration_date)
|
|
372
|
+
return self._admin_users_set_expiration(user_id=uid, expiration_ts=expiration_ts, workspace_id=workspace_id)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------- discovery helper ----------
|
|
376
|
+
|
|
377
|
+
def get_channels(self, user_id: str, active_only: bool = True) -> List[Dict[str, Any]]:
|
|
378
|
+
"""
|
|
379
|
+
discovery.user.conversations, paginated by offset.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
- If errors occur: a list of error dicts (legacy behavior preserved)
|
|
383
|
+
- Else: list of channels (active_only controls whether it filters to channels with date_left == 0)
|
|
384
|
+
"""
|
|
385
|
+
current_channels: List[Dict[str, Any]] = []
|
|
386
|
+
all_channels: List[Dict[str, Any]] = []
|
|
387
|
+
errors: List[Dict[str, Any]] = []
|
|
388
|
+
|
|
389
|
+
payload: Dict[str, Any] = {"user": user_id, "limit": 1000}
|
|
390
|
+
|
|
391
|
+
while True:
|
|
392
|
+
resp = self._discovery_user_conversations(payload)
|
|
393
|
+
|
|
394
|
+
if not resp.get("ok"):
|
|
395
|
+
errors.append({"message": resp.get("error", "unknown_error"), "payload": dict(payload)})
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
channels = resp.get("channels") or []
|
|
399
|
+
for ch in channels:
|
|
400
|
+
all_channels.append(ch)
|
|
401
|
+
if ch.get("date_left", 0) == 0:
|
|
402
|
+
current_channels.append(ch)
|
|
403
|
+
|
|
404
|
+
offset = resp.get("offset")
|
|
405
|
+
if offset:
|
|
406
|
+
payload["offset"] = offset
|
|
407
|
+
else:
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if errors:
|
|
411
|
+
return errors # preserve legacy behavior
|
|
412
|
+
|
|
413
|
+
return current_channels if active_only else all_channels
|
|
414
|
+
|
|
415
|
+
# ============================================================
|
|
416
|
+
# SCIM (requests) - already modular via _scim_request
|
|
417
|
+
# ============================================================
|
|
418
|
+
|
|
419
|
+
def _scim_base_url(self, scim_version: str) -> str:
|
|
420
|
+
"""Return SCIM base URL, allowing config override."""
|
|
421
|
+
if scim_version == "v2" and getattr(self.cfg, "scim_base_url_v2", None):
|
|
422
|
+
return self.cfg.scim_base_url_v2.rstrip("/") + "/"
|
|
423
|
+
if scim_version == "v1" and getattr(self.cfg, "scim_base_url_v1", None):
|
|
424
|
+
return self.cfg.scim_base_url_v1.rstrip("/") + "/"
|
|
425
|
+
return f"https://api.slack.com/scim/{scim_version}/"
|
|
426
|
+
|
|
427
|
+
def _scim_request(
|
|
428
|
+
self,
|
|
429
|
+
*,
|
|
430
|
+
path: str,
|
|
431
|
+
method: str,
|
|
432
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
433
|
+
scim_version: str = "v1",
|
|
434
|
+
token: Optional[str] = None,
|
|
435
|
+
) -> ScimResponse:
|
|
436
|
+
"""
|
|
437
|
+
Perform a SCIM REST request and return a structured response.
|
|
438
|
+
|
|
439
|
+
Note: SCIM rate limiting is separate from Slack Web API rate limiting; we keep a small,
|
|
440
|
+
conservative sleep here. If you later unify SCIM throttling with SlackApiCaller, you can
|
|
441
|
+
remove this sleep.
|
|
442
|
+
"""
|
|
443
|
+
tok = token or getattr(self.cfg, "scim_token", None)
|
|
444
|
+
if not tok:
|
|
445
|
+
raise ValueError("SCIM request requires cfg.scim_token (or token override)")
|
|
446
|
+
|
|
447
|
+
# Simple pacing to avoid bursts (tweak as needed)
|
|
448
|
+
time.sleep(float(RateTier.TIER_2))
|
|
449
|
+
|
|
450
|
+
url = self._scim_base_url(scim_version) + path.lstrip("/")
|
|
451
|
+
headers = {
|
|
452
|
+
"Authorization": f"Bearer {tok}",
|
|
453
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
resp = self.scim_session.request(
|
|
457
|
+
method=method.upper(),
|
|
458
|
+
url=url,
|
|
459
|
+
headers=headers,
|
|
460
|
+
data=json.dumps(payload) if payload is not None else None,
|
|
461
|
+
timeout=getattr(self.cfg, "http_timeout_seconds", 30),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
text = resp.text or ""
|
|
465
|
+
try:
|
|
466
|
+
data = resp.json() if text else {}
|
|
467
|
+
except Exception:
|
|
468
|
+
data = {}
|
|
469
|
+
|
|
470
|
+
ok = resp.ok and (data.get("Errors") is None)
|
|
471
|
+
return ScimResponse(ok=ok, status_code=resp.status_code, data=data, text=text)
|
|
472
|
+
|
|
473
|
+
def scim_create_user(self, username: str, email: str, scim_version: str = "v1") -> ScimResponse:
|
|
474
|
+
"""SCIM POST Users"""
|
|
475
|
+
if scim_version == "v2":
|
|
476
|
+
payload: Dict[str, Any] = {
|
|
477
|
+
"schemas": [
|
|
478
|
+
"urn:ietf:params:scim:schemas:core:2.0:User",
|
|
479
|
+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
|
480
|
+
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
|
|
481
|
+
],
|
|
482
|
+
"userName": username,
|
|
483
|
+
"emails": [{"value": email}],
|
|
484
|
+
}
|
|
485
|
+
elif scim_version == "v1":
|
|
486
|
+
payload = {
|
|
487
|
+
"schemas": [
|
|
488
|
+
"urn:scim:schemas:core:1.0",
|
|
489
|
+
"urn:scim:schemas:extension:enterprise:1.0",
|
|
490
|
+
"urn:scim:schemas:extension:slack:profile:1.0",
|
|
491
|
+
],
|
|
492
|
+
"userName": username,
|
|
493
|
+
"emails": [{"value": email}],
|
|
494
|
+
}
|
|
495
|
+
else:
|
|
496
|
+
raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
|
|
497
|
+
|
|
498
|
+
return self._scim_request(path="Users", method="POST", payload=payload, scim_version=scim_version)
|
|
499
|
+
|
|
500
|
+
def scim_deactivate_user(self, user_id: str, scim_version: str = "v1") -> ScimResponse:
|
|
501
|
+
"""SCIM DELETE Users/<id>"""
|
|
502
|
+
return self._scim_request(path=f"Users/{user_id}", method="DELETE", scim_version=scim_version)
|
|
503
|
+
|
|
504
|
+
def scim_update_user_attribute(
|
|
505
|
+
self,
|
|
506
|
+
*,
|
|
507
|
+
user_id: str,
|
|
508
|
+
attribute: str,
|
|
509
|
+
new_value: Any,
|
|
510
|
+
scim_version: str = "v2",
|
|
511
|
+
) -> ScimResponse:
|
|
512
|
+
"""SCIM PATCH Users/<id>"""
|
|
513
|
+
if scim_version == "v2":
|
|
514
|
+
payload = {
|
|
515
|
+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
516
|
+
"Operations": [{"op": "replace", "path": attribute, "value": new_value}],
|
|
517
|
+
}
|
|
518
|
+
elif scim_version == "v1":
|
|
519
|
+
payload = {"schemas": ["urn:scim:schemas:core:1.0"], attribute: new_value}
|
|
520
|
+
else:
|
|
521
|
+
raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
|
|
522
|
+
|
|
523
|
+
return self._scim_request(path=f"Users/{user_id}", method="PATCH", payload=payload, scim_version=scim_version)
|
|
524
|
+
|
|
525
|
+
def make_multi_channel_guest(self, user_id: Optional[str] = None, scim_version: str = "v1") -> ScimResponse:
|
|
526
|
+
"""Convert a user to a multi-channel guest via SCIM PATCH."""
|
|
527
|
+
uid = user_id or self.user_id
|
|
528
|
+
if not uid:
|
|
529
|
+
raise ValueError("make_multi_channel_guest requires user_id (passed or bound)")
|
|
530
|
+
|
|
531
|
+
if scim_version == "v2":
|
|
532
|
+
payload = {
|
|
533
|
+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
534
|
+
"Operations": [
|
|
535
|
+
{
|
|
536
|
+
"path": "urn:ietf:params:scim:schemas:extension:slack:guest:2.0:User",
|
|
537
|
+
"op": "add",
|
|
538
|
+
"value": {"type": "multi"},
|
|
539
|
+
}
|
|
540
|
+
],
|
|
541
|
+
}
|
|
542
|
+
elif scim_version == "v1":
|
|
543
|
+
payload = {
|
|
544
|
+
"schemas": [
|
|
545
|
+
"urn:scim:schemas:core:1.0",
|
|
546
|
+
"urn:scim:schemas:extension:enterprise:1.0",
|
|
547
|
+
"urn:scim:schemas:extension:slack:guest:1.0",
|
|
548
|
+
],
|
|
549
|
+
"urn:scim:schemas:extension:slack:guest:1.0": {"type": "multi"},
|
|
550
|
+
}
|
|
551
|
+
else:
|
|
552
|
+
raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
|
|
553
|
+
|
|
554
|
+
return self._scim_request(path=f"Users/{uid}", method="PATCH", payload=payload, scim_version=scim_version)
|