slack-objects 0.0.2.dev0__tar.gz → 0.0.3__tar.gz
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-0.0.2.dev0 → slack_objects-0.0.3}/.gitignore +2 -1
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/PKG-INFO +8 -7
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/README.md +4 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/pyproject.toml +3 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/_version.py +3 -3
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/api_caller.py +9 -7
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/base.py +19 -0
- slack_objects-0.0.3/src/slack_objects/config.py +57 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/conversations.py +2 -2
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/idp_groups.py +15 -69
- slack_objects-0.0.3/src/slack_objects/scim_base.py +106 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/users.py +47 -82
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/PKG-INFO +8 -7
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/SOURCES.txt +3 -0
- slack_objects-0.0.3/src/slack_objects.egg-info/requires.txt +8 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/_smoke_harness.py +40 -9
- slack_objects-0.0.3/tests/api_caller_smoke_test.py +166 -0
- slack_objects-0.0.3/tests/idp_groups_smoke_test.py +70 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/run_all_smoke.py +6 -2
- slack_objects-0.0.3/tests/security_smoke_test.py +109 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_smoke_test.py +9 -7
- slack_objects-0.0.2.dev0/src/slack_objects/config.py +0 -29
- slack_objects-0.0.2.dev0/src/slack_objects.egg-info/requires.txt +0 -8
- slack_objects-0.0.2.dev0/tests/idp_groups_smoke_test.py +0 -37
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/ci.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/publish-pypi.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.github/workflows/publish-testpypi.yml +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.vs/VSWorkspaceState.json +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/.vs/slnx.sqlite +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/LICENSE +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/pytest.ini +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/setup.cfg +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/__init__.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/client.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/files.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/messages.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/rate_limits.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects/workspaces.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/dependency_links.txt +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/src/slack_objects.egg-info/top_level.txt +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/conversations_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/conversations_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/files_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/files_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/idp_groups_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/messages_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/messages_smoke_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/users_test_AzureKeyVault.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/workspaces_example_test.py +0 -0
- {slack_objects-0.0.2.dev0 → slack_objects-0.0.3}/tests/workspaces_smoke_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-objects
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
|
|
5
5
|
Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
|
|
6
6
|
Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
|
|
@@ -10,9 +10,9 @@ Classifier: Operating System :: OS Independent
|
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: slack-sdk
|
|
14
|
-
Requires-Dist: PC_Utils
|
|
15
|
-
Requires-Dist: requests
|
|
13
|
+
Requires-Dist: slack-sdk<4,>=3.27
|
|
14
|
+
Requires-Dist: PC_Utils>=0.1
|
|
15
|
+
Requires-Dist: requests<3,>=2.31
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: pytest; extra == "dev"
|
|
18
18
|
Requires-Dist: build; extra == "dev"
|
|
@@ -67,8 +67,9 @@ Instead, it focuses on higher-level object operations that typically require:
|
|
|
67
67
|
All object helpers are created from a single entry point:
|
|
68
68
|
|
|
69
69
|
```python
|
|
70
|
-
from slack_objects
|
|
70
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig
|
|
71
71
|
|
|
72
|
+
cfg = SlackObjectsConfig(bot_token="xoxb-...", user_token="xoxp-...", scim_token="xoxp-...", ...)
|
|
72
73
|
slack = SlackObjectsClient(cfg)
|
|
73
74
|
|
|
74
75
|
users = slack.users()
|
|
@@ -157,6 +158,6 @@ python -m tests.run_all_smoke
|
|
|
157
158
|
|
|
158
159
|
## Notes
|
|
159
160
|
|
|
160
|
-
- SCIM
|
|
161
|
-
-
|
|
161
|
+
- SCIM v2 is the default; v1 is supported where applicable
|
|
162
|
+
- `PC_Utils` is a required dependency (used for datetime handling, e.g., guest expiration dates)
|
|
162
163
|
- This package is intended for automation and administration workflows
|
|
@@ -46,8 +46,9 @@ Instead, it focuses on higher-level object operations that typically require:
|
|
|
46
46
|
All object helpers are created from a single entry point:
|
|
47
47
|
|
|
48
48
|
```python
|
|
49
|
-
from slack_objects
|
|
49
|
+
from slack_objects import SlackObjectsClient, SlackObjectsConfig
|
|
50
50
|
|
|
51
|
+
cfg = SlackObjectsConfig(bot_token="xoxb-...", user_token="xoxp-...", scim_token="xoxp-...", ...)
|
|
51
52
|
slack = SlackObjectsClient(cfg)
|
|
52
53
|
|
|
53
54
|
users = slack.users()
|
|
@@ -136,6 +137,6 @@ python -m tests.run_all_smoke
|
|
|
136
137
|
|
|
137
138
|
## Notes
|
|
138
139
|
|
|
139
|
-
- SCIM
|
|
140
|
-
-
|
|
140
|
+
- SCIM v2 is the default; v1 is supported where applicable
|
|
141
|
+
- `PC_Utils` is a required dependency (used for datetime handling, e.g., guest expiration dates)
|
|
141
142
|
- This package is intended for automation and administration workflows
|
|
@@ -24,9 +24,9 @@ classifiers=[
|
|
|
24
24
|
keywords = ["slack", "objects", "classes", "slack objects", "utilities", "slack utilities", "slack object types", "slack types", "types"]
|
|
25
25
|
|
|
26
26
|
dependencies = [
|
|
27
|
-
"slack-sdk",
|
|
28
|
-
"PC_Utils",
|
|
29
|
-
"requests",
|
|
27
|
+
"slack-sdk>=3.27,<4",
|
|
28
|
+
"PC_Utils>=0.1",
|
|
29
|
+
"requests>=2.31,<3",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 3)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g51cb2b9df'
|
|
@@ -17,7 +17,8 @@ class SlackApiCaller:
|
|
|
17
17
|
self.cfg = cfg
|
|
18
18
|
self.policy = policy
|
|
19
19
|
|
|
20
|
-
def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, **kwargs) -> dict:
|
|
20
|
+
def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, _retry_count: int = 0, **kwargs) -> dict:
|
|
21
|
+
MAX_RETRIES = 5
|
|
21
22
|
tier = rate_tier or self.policy.tier_for(method) or self.cfg.default_rate_tier
|
|
22
23
|
|
|
23
24
|
try:
|
|
@@ -27,16 +28,17 @@ class SlackApiCaller:
|
|
|
27
28
|
resp = client.api_call(method, params=kwargs)
|
|
28
29
|
|
|
29
30
|
data = resp.data if hasattr(resp, "data") else resp
|
|
30
|
-
|
|
31
|
-
# Space out subsequent calls
|
|
32
31
|
time.sleep(float(tier))
|
|
33
32
|
return data
|
|
34
33
|
|
|
35
34
|
except SlackApiError as e:
|
|
36
|
-
# Handle rate limiting properly
|
|
37
35
|
if e.response is not None and e.response.status_code == 429:
|
|
38
|
-
|
|
36
|
+
if _retry_count >= MAX_RETRIES:
|
|
37
|
+
raise RuntimeError(f"Rate-limited {MAX_RETRIES} times on {method}; giving up.") from e
|
|
38
|
+
try:
|
|
39
|
+
retry_after = int(e.response.headers.get("Retry-After", tier))
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
retry_after = int(float(tier))
|
|
39
42
|
time.sleep(retry_after)
|
|
40
|
-
return self.call(client, method, rate_tier=tier, **kwargs)
|
|
41
|
-
|
|
43
|
+
return self.call(client, method, rate_tier=tier, use_json=use_json, _retry_count=_retry_count + 1, **kwargs)
|
|
42
44
|
raise
|
|
@@ -7,6 +7,25 @@ from .api_caller import SlackApiCaller
|
|
|
7
7
|
from .config import SlackObjectsConfig
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
# Keys that are safe to include in error messages (never contain tokens)
|
|
11
|
+
_SAFE_ERROR_KEYS = frozenset({"ok", "error", "needed", "provided", "response_metadata"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_error_context(resp: Any, *, max_len: int = 300) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Return a truncated, token-free summary of an API response for use in exception messages.
|
|
17
|
+
|
|
18
|
+
Only well-known diagnostic keys are kept. The result is capped at *max_len* characters
|
|
19
|
+
to prevent massive payloads from flooding logs or error-tracking systems.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(resp, dict):
|
|
22
|
+
summary = {k: v for k, v in resp.items() if k in _SAFE_ERROR_KEYS}
|
|
23
|
+
else:
|
|
24
|
+
summary = repr(resp)
|
|
25
|
+
text = str(summary)
|
|
26
|
+
return text[:max_len] + ("..." if len(text) > max_len else "")
|
|
27
|
+
|
|
28
|
+
|
|
10
29
|
@dataclass
|
|
11
30
|
class SlackObjectBase:
|
|
12
31
|
"""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RateTier(float, Enum):
|
|
7
|
+
"""
|
|
8
|
+
Slack API rate-tier backoff defaults (seconds). These are defined to conform to Slack Web API rate limits.
|
|
9
|
+
https://docs.slack.dev/apis/web-api/rate-limits/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
TIER_1 = 60.0 # 1+ per minute
|
|
13
|
+
TIER_2 = 3.0 # 20+ per minute
|
|
14
|
+
TIER_3 = 1.2 # 50+ per minute
|
|
15
|
+
TIER_4 = 0.6 # 100+ per minute
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SlackObjectsConfig:
|
|
20
|
+
"""
|
|
21
|
+
Configuration settings for slack-objects.
|
|
22
|
+
|
|
23
|
+
Tokens are optional at construction time.
|
|
24
|
+
Individual methods will raise clear errors if a required token is missing.
|
|
25
|
+
"""
|
|
26
|
+
bot_token: Optional[str] = field(default=None, repr=False)
|
|
27
|
+
user_token: Optional[str] = field(default=None, repr=False)
|
|
28
|
+
scim_token: Optional[str] = field(default=None, repr=False)
|
|
29
|
+
|
|
30
|
+
default_rate_tier: RateTier = RateTier.TIER_2
|
|
31
|
+
|
|
32
|
+
auth_idp_groups_read_access: dict[str, list[str]] = field(default_factory=dict)
|
|
33
|
+
auth_idp_groups_write_access: dict[str, list[str]] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
# SCIM settings
|
|
36
|
+
scim_base_url: str = "https://api.slack.com/scim"
|
|
37
|
+
scim_version: str = "v2"
|
|
38
|
+
|
|
39
|
+
# HTTP timeout for SCIM and file-download requests (seconds)
|
|
40
|
+
http_timeout_seconds: int = 30
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
""" Modifying the default dataclass __repr__ to mask token values for security. """
|
|
44
|
+
def _mask(val: Optional[str]) -> str:
|
|
45
|
+
return "***" if val else "None"
|
|
46
|
+
return (
|
|
47
|
+
f"SlackObjectsConfig("
|
|
48
|
+
f"bot_token={_mask(self.bot_token)}, "
|
|
49
|
+
f"user_token={_mask(self.user_token)}, "
|
|
50
|
+
f"scim_token={_mask(self.scim_token)}, "
|
|
51
|
+
f"default_rate_tier={self.default_rate_tier}, "
|
|
52
|
+
f"auth_idp_groups_read_access={self.auth_idp_groups_read_access}, "
|
|
53
|
+
f"auth_idp_groups_write_access={self.auth_idp_groups_write_access}, "
|
|
54
|
+
f"scim_base_url={self.scim_base_url}, "
|
|
55
|
+
f"scim_version={self.scim_version}, "
|
|
56
|
+
f"http_timeout_seconds={self.http_timeout_seconds})"
|
|
57
|
+
)
|
|
@@ -33,7 +33,7 @@ from dataclasses import dataclass, field
|
|
|
33
33
|
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
from .base import SlackObjectBase
|
|
36
|
+
from .base import SlackObjectBase, safe_error_context
|
|
37
37
|
from .config import RateTier
|
|
38
38
|
from .messages import Messages
|
|
39
39
|
|
|
@@ -75,7 +75,7 @@ class Conversations(SlackObjectBase):
|
|
|
75
75
|
|
|
76
76
|
resp = self.get_conversation_info(self.channel_id)
|
|
77
77
|
if not resp.get("ok"):
|
|
78
|
-
raise RuntimeError(f"Conversations.get_conversation_info() failed: {resp}")
|
|
78
|
+
raise RuntimeError(f"Conversations.get_conversation_info() failed: {safe_error_context(resp)}")
|
|
79
79
|
|
|
80
80
|
self.attributes = resp.get("channel") or {}
|
|
81
81
|
return self.attributes
|
|
@@ -17,7 +17,7 @@ This module implements the following functionality:
|
|
|
17
17
|
|
|
18
18
|
Design decisions
|
|
19
19
|
----------------
|
|
20
|
-
- SCIM REST calls are centralized in
|
|
20
|
+
- SCIM REST calls are centralized in ScimMixin._scim_request(); all public methods call endpoint wrappers.
|
|
21
21
|
- Uses an injectable `requests.Session` (`scim_session`) so tests can pass a fake session.
|
|
22
22
|
- Keeps legacy output shapes: lists of dicts for groups and members.
|
|
23
23
|
"""
|
|
@@ -25,16 +25,14 @@ Design decisions
|
|
|
25
25
|
from dataclasses import dataclass, field
|
|
26
26
|
from typing import Any, Dict, List, Optional
|
|
27
27
|
|
|
28
|
-
import json
|
|
29
|
-
import time
|
|
30
28
|
import requests
|
|
31
29
|
|
|
32
30
|
from .base import SlackObjectBase
|
|
33
|
-
from .
|
|
31
|
+
from .scim_base import ScimMixin, ScimResponse, validate_scim_id
|
|
34
32
|
|
|
35
33
|
|
|
36
34
|
@dataclass
|
|
37
|
-
class IDP_groups(SlackObjectBase):
|
|
35
|
+
class IDP_groups(ScimMixin, SlackObjectBase):
|
|
38
36
|
"""
|
|
39
37
|
IdP (SCIM) groups helper.
|
|
40
38
|
|
|
@@ -60,78 +58,26 @@ class IDP_groups(SlackObjectBase):
|
|
|
60
58
|
scim_session=self.scim_session,
|
|
61
59
|
)
|
|
62
60
|
|
|
63
|
-
# ---------- SCIM request wrapper ----------
|
|
64
|
-
def _scim_base_url(self, scim_version: str) -> str:
|
|
65
|
-
"""Return configurable SCIM base URL; default to Slack SCIM endpoints when not overridden."""
|
|
66
|
-
if scim_version == "v2" and getattr(self.cfg, "scim_base_url_v2", None):
|
|
67
|
-
return self.cfg.scim_base_url_v2.rstrip("/") + "/"
|
|
68
|
-
if scim_version == "v1" and getattr(self.cfg, "scim_base_url_v1", None):
|
|
69
|
-
return self.cfg.scim_base_url_v1.rstrip("/") + "/"
|
|
70
|
-
return f"https://api.slack.com/scim/{scim_version}/"
|
|
71
|
-
|
|
72
|
-
def _scim_request(
|
|
73
|
-
self,
|
|
74
|
-
*,
|
|
75
|
-
path: str,
|
|
76
|
-
method: str = "GET",
|
|
77
|
-
payload: Optional[Dict[str, Any]] = None,
|
|
78
|
-
scim_version: str = "v1",
|
|
79
|
-
token: Optional[str] = None,
|
|
80
|
-
params: Optional[Dict[str, Any]] = None,
|
|
81
|
-
) -> Dict[str, Any]:
|
|
82
|
-
"""
|
|
83
|
-
Low-level SCIM request. Returns parsed JSON dict.
|
|
84
|
-
|
|
85
|
-
It raises ValueError when token is missing. Network/HTTP errors will raise requests exceptions.
|
|
86
|
-
We add a small sleep based on RateTier to reduce burstiness (keeps legacy cautious behavior).
|
|
87
|
-
"""
|
|
88
|
-
tok = token or getattr(self.cfg, "scim_token", None)
|
|
89
|
-
if not tok:
|
|
90
|
-
raise ValueError("SCIM request requires cfg.scim_token (or token override)")
|
|
91
|
-
|
|
92
|
-
# conservative sleep to avoid bursts (can be tuned)
|
|
93
|
-
time.sleep(float(RateTier.TIER_2))
|
|
94
|
-
|
|
95
|
-
url = self._scim_base_url(scim_version) + path.lstrip("/")
|
|
96
|
-
headers = {
|
|
97
|
-
"Authorization": f"Bearer {tok}",
|
|
98
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
resp = self.scim_session.request(
|
|
102
|
-
method=method.upper(),
|
|
103
|
-
url=url,
|
|
104
|
-
headers=headers,
|
|
105
|
-
params=params,
|
|
106
|
-
json=payload,
|
|
107
|
-
timeout=getattr(self.cfg, "http_timeout_seconds", 30),
|
|
108
|
-
)
|
|
109
|
-
resp.raise_for_status()
|
|
110
|
-
# best-effort JSON parse; return empty dict if no body
|
|
111
|
-
try:
|
|
112
|
-
return resp.json() if resp.text else {}
|
|
113
|
-
except Exception:
|
|
114
|
-
return {"_raw_text": resp.text or ""}
|
|
115
|
-
|
|
116
61
|
# ---------- endpoint wrappers (only these call _scim_request) ----------
|
|
117
62
|
|
|
118
|
-
def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None
|
|
63
|
+
def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None) -> Dict[str, Any]:
|
|
119
64
|
"""
|
|
120
65
|
Wrapper for GET Groups (paginated).
|
|
121
66
|
Accepts pagination params as query parameters according to Slack SCIM docs.
|
|
122
67
|
"""
|
|
123
|
-
params = {"count": count}
|
|
68
|
+
params: Dict[str, Any] = {"count": count}
|
|
124
69
|
if start_index:
|
|
125
70
|
params["startIndex"] = start_index
|
|
126
|
-
return self._scim_request(path="Groups", method="GET", params=params
|
|
71
|
+
return self._scim_request(path="Groups", method="GET", params=params).data
|
|
127
72
|
|
|
128
|
-
def _scim_group_get(self, group_id: str
|
|
73
|
+
def _scim_group_get(self, group_id: str) -> Dict[str, Any]:
|
|
129
74
|
"""Wrapper for GET Groups/{id}"""
|
|
130
|
-
|
|
75
|
+
validate_scim_id(group_id, "group_id")
|
|
76
|
+
return self._scim_request(path=f"Groups/{group_id}", method="GET").data
|
|
131
77
|
|
|
132
78
|
# ---------- public helpers ----------
|
|
133
79
|
|
|
134
|
-
def get_groups(self,
|
|
80
|
+
def get_groups(self, fetch_count: int = 1000) -> List[Dict[str, str]]:
|
|
135
81
|
"""
|
|
136
82
|
Return a list of IdP groups visible to the SCIM token.
|
|
137
83
|
|
|
@@ -147,7 +93,7 @@ class IDP_groups(SlackObjectBase):
|
|
|
147
93
|
retrieved = 0
|
|
148
94
|
|
|
149
95
|
while True:
|
|
150
|
-
resp = self._scim_groups_list(count=fetch_count, start_index=start_index
|
|
96
|
+
resp = self._scim_groups_list(count=fetch_count, start_index=start_index)
|
|
151
97
|
|
|
152
98
|
# Slack SCIM returns 'Resources' (list) and 'totalResults' and 'startIndex' values.
|
|
153
99
|
resources = resp.get("Resources", []) or []
|
|
@@ -173,7 +119,7 @@ class IDP_groups(SlackObjectBase):
|
|
|
173
119
|
|
|
174
120
|
return groups_out
|
|
175
121
|
|
|
176
|
-
def get_members(self, group_id: Optional[str] = None
|
|
122
|
+
def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
|
|
177
123
|
"""
|
|
178
124
|
Return the members of a group as a list of dicts `{'value': <user_id>, 'display': <name>}`.
|
|
179
125
|
|
|
@@ -183,16 +129,16 @@ class IDP_groups(SlackObjectBase):
|
|
|
183
129
|
if not gid:
|
|
184
130
|
raise ValueError("get_members requires group_id (passed or bound)")
|
|
185
131
|
|
|
186
|
-
resp = self._scim_group_get(gid
|
|
132
|
+
resp = self._scim_group_get(gid)
|
|
187
133
|
# In the legacy scripts, group members are at `members` in the response body
|
|
188
134
|
return resp.get("members", [])
|
|
189
135
|
|
|
190
|
-
def is_member(self, user_id: str, group_id: Optional[str] = None
|
|
136
|
+
def is_member(self, user_id: str, group_id: Optional[str] = None) -> bool:
|
|
191
137
|
"""
|
|
192
138
|
Return True if `user_id` is a member of `group_id`.
|
|
193
139
|
Preserves legacy semantics (scans the members list).
|
|
194
140
|
"""
|
|
195
|
-
members = self.get_members(group_id=group_id
|
|
141
|
+
members = self.get_members(group_id=group_id)
|
|
196
142
|
for member in members:
|
|
197
143
|
# member dicts historically had 'value' for id
|
|
198
144
|
if member.get("value") == user_id:
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared SCIM plumbing for any object helper that makes SCIM REST calls.
|
|
3
|
+
|
|
4
|
+
Centralizes:
|
|
5
|
+
- ID validation (path-injection defense)
|
|
6
|
+
- Base URL construction
|
|
7
|
+
- Token-guarded HTTP request + JSON parsing
|
|
8
|
+
- Rate-tier sleep
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from .config import RateTier
|
|
22
|
+
|
|
23
|
+
# Slack IDs are alphanumeric with hyphens/underscores.
|
|
24
|
+
_SLACK_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_scim_id(value: str, label: str = "id") -> str:
|
|
28
|
+
"""Raise ValueError if *value* contains path-traversal or unexpected characters."""
|
|
29
|
+
if not value or not _SLACK_ID_RE.match(value):
|
|
30
|
+
raise ValueError(f"Invalid {label}: {value!r}")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ScimResponse:
|
|
36
|
+
"""Structured result for SCIM calls (no Slack 'ok' boolean)."""
|
|
37
|
+
ok: bool
|
|
38
|
+
status_code: int
|
|
39
|
+
data: Dict[str, Any]
|
|
40
|
+
text: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScimMixin:
|
|
44
|
+
"""
|
|
45
|
+
Mixin providing SCIM REST helpers.
|
|
46
|
+
|
|
47
|
+
Requirements on the host class (satisfied by SlackObjectBase subclasses):
|
|
48
|
+
- self.cfg (SlackObjectsConfig)
|
|
49
|
+
- self.scim_session (requests.Session)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# --- URL ---
|
|
53
|
+
|
|
54
|
+
def _scim_base_url(self) -> str:
|
|
55
|
+
return f"{self.cfg.scim_base_url.rstrip('/')}/{self.cfg.scim_version}/"
|
|
56
|
+
|
|
57
|
+
# --- Low-level request ---
|
|
58
|
+
|
|
59
|
+
def _scim_request(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
path: str,
|
|
63
|
+
method: str = "GET",
|
|
64
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
65
|
+
token: Optional[str] = None,
|
|
66
|
+
params: Optional[Dict[str, Any]] = None,
|
|
67
|
+
raise_for_status: bool = True,
|
|
68
|
+
) -> ScimResponse:
|
|
69
|
+
"""
|
|
70
|
+
Perform a SCIM REST request and return a ScimResponse.
|
|
71
|
+
|
|
72
|
+
Raises ValueError when the token is missing.
|
|
73
|
+
Raises requests.HTTPError on non-2xx when raise_for_status is True.
|
|
74
|
+
"""
|
|
75
|
+
tok = token or self.cfg.scim_token
|
|
76
|
+
if not tok:
|
|
77
|
+
raise ValueError("SCIM request requires cfg.scim_token (or token override)")
|
|
78
|
+
|
|
79
|
+
url = self._scim_base_url() + path.lstrip("/")
|
|
80
|
+
headers = {
|
|
81
|
+
"Authorization": f"Bearer {tok}",
|
|
82
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
resp = self.scim_session.request(
|
|
86
|
+
method=method.upper(),
|
|
87
|
+
url=url,
|
|
88
|
+
headers=headers,
|
|
89
|
+
params=params,
|
|
90
|
+
json=payload,
|
|
91
|
+
timeout=self.cfg.http_timeout_seconds,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if raise_for_status:
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
|
|
97
|
+
text = resp.text or ""
|
|
98
|
+
try:
|
|
99
|
+
data = resp.json() if text else {}
|
|
100
|
+
except Exception:
|
|
101
|
+
data = {"_raw_text": text}
|
|
102
|
+
|
|
103
|
+
ok = resp.ok and (data.get("Errors") is None)
|
|
104
|
+
|
|
105
|
+
time.sleep(float(RateTier.TIER_2))
|
|
106
|
+
return ScimResponse(ok=ok, status_code=resp.status_code, data=data, text=text)
|