sapsf-shared 0.1.0__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.
- sapsf_shared/__init__.py +45 -0
- sapsf_shared/auth.py +357 -0
- sapsf_shared/client.py +344 -0
- sapsf_shared/config.py +153 -0
- sapsf_shared/exceptions.py +37 -0
- sapsf_shared/flask_base.py +186 -0
- sapsf_shared/logging_config.py +98 -0
- sapsf_shared/utils.py +143 -0
- sapsf_shared-0.1.0.dist-info/METADATA +224 -0
- sapsf_shared-0.1.0.dist-info/RECORD +12 -0
- sapsf_shared-0.1.0.dist-info/WHEEL +4 -0
- sapsf_shared-0.1.0.dist-info/licenses/LICENSE +21 -0
sapsf_shared/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""sapsf-shared — Shared Python SDK for SAP SuccessFactors tools."""
|
|
2
|
+
|
|
3
|
+
from sapsf_shared.auth import (
|
|
4
|
+
AuthConfig,
|
|
5
|
+
AuthError,
|
|
6
|
+
BasicAuth,
|
|
7
|
+
CertificateAuth,
|
|
8
|
+
CredentialStore,
|
|
9
|
+
OAuth2Auth,
|
|
10
|
+
build_auth_headers,
|
|
11
|
+
build_requests_auth,
|
|
12
|
+
)
|
|
13
|
+
from sapsf_shared.client import SFClient
|
|
14
|
+
from sapsf_shared.config import SFEnvConfig, load_config, load_yaml
|
|
15
|
+
from sapsf_shared.exceptions import SFClientError, SFConfigError, SFError
|
|
16
|
+
from sapsf_shared.logging_config import setup_logging
|
|
17
|
+
from sapsf_shared.utils import (
|
|
18
|
+
build_odata_filter,
|
|
19
|
+
flatten_record,
|
|
20
|
+
is_active_today,
|
|
21
|
+
parse_sf_date,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AuthConfig",
|
|
26
|
+
"AuthError",
|
|
27
|
+
"BasicAuth",
|
|
28
|
+
"CertificateAuth",
|
|
29
|
+
"CredentialStore",
|
|
30
|
+
"OAuth2Auth",
|
|
31
|
+
"SFClient",
|
|
32
|
+
"SFEnvConfig",
|
|
33
|
+
"SFClientError",
|
|
34
|
+
"SFConfigError",
|
|
35
|
+
"SFError",
|
|
36
|
+
"build_auth_headers",
|
|
37
|
+
"build_odata_filter",
|
|
38
|
+
"build_requests_auth",
|
|
39
|
+
"flatten_record",
|
|
40
|
+
"is_active_today",
|
|
41
|
+
"load_config",
|
|
42
|
+
"load_yaml",
|
|
43
|
+
"parse_sf_date",
|
|
44
|
+
"setup_logging",
|
|
45
|
+
]
|
sapsf_shared/auth.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Authentication layer for SAP SuccessFactors OData APIs.
|
|
2
|
+
|
|
3
|
+
Supports three auth methods:
|
|
4
|
+
- basic: HTTP Basic Auth (username + password)
|
|
5
|
+
- oauth2: OAuth 2.0 client credentials grant
|
|
6
|
+
- certificate: Mutual TLS with client cert + private key
|
|
7
|
+
|
|
8
|
+
Credential storage uses the OS keyring when available; falls back to a
|
|
9
|
+
local chmod-600 JSON file on headless systems (WSL, CI, etc.).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
from requests.auth import AuthBase, HTTPBasicAuth
|
|
27
|
+
|
|
28
|
+
from sapsf_shared.exceptions import AuthError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_DEFAULT_KEYRING_SERVICE = "sapsf_shared"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _detect_keyring(service: str) -> bool:
|
|
36
|
+
"""Probe whether a keyring backend is functional."""
|
|
37
|
+
try:
|
|
38
|
+
import keyring as _keyring
|
|
39
|
+
|
|
40
|
+
_keyring.get_password(service, "__probe__")
|
|
41
|
+
return True
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CredentialStore:
|
|
47
|
+
"""Secure credential storage with keyring fallback to a local JSON file.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
store = CredentialStore(service="my_tool")
|
|
51
|
+
store.set("prd:password", "secret123")
|
|
52
|
+
pwd = store.get("prd:password")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
service: str = _DEFAULT_KEYRING_SERVICE,
|
|
59
|
+
fallback_path: Path | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.service = service
|
|
62
|
+
self._use_keyring = _detect_keyring(service)
|
|
63
|
+
if not self._use_keyring:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"No keyring backend available; using local fallback file "
|
|
66
|
+
"(chmod 600 applied)."
|
|
67
|
+
)
|
|
68
|
+
self._fallback = fallback_path or (
|
|
69
|
+
Path(__file__).parent.parent.parent / ".secrets.json"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Internal file helpers
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _file_load(self) -> dict[str, str]:
|
|
77
|
+
if self._fallback.exists():
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(self._fallback.read_text())
|
|
80
|
+
except Exception:
|
|
81
|
+
return {}
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
def _file_save(self, data: dict[str, str]) -> None:
|
|
85
|
+
tmp = Path(str(self._fallback) + ".tmp")
|
|
86
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
87
|
+
os.replace(tmp, self._fallback)
|
|
88
|
+
try:
|
|
89
|
+
os.chmod(self._fallback, 0o600)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
logger.warning("chmod 600 on %s failed: %s", self._fallback, exc)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Public API
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def set(self, key: str, value: str) -> None:
|
|
98
|
+
"""Persist *value* under *key*."""
|
|
99
|
+
if self._use_keyring:
|
|
100
|
+
import keyring as _keyring
|
|
101
|
+
|
|
102
|
+
_keyring.set_password(self.service, key, value)
|
|
103
|
+
else:
|
|
104
|
+
data = self._file_load()
|
|
105
|
+
data[key] = value
|
|
106
|
+
self._file_save(data)
|
|
107
|
+
|
|
108
|
+
def get(self, key: str) -> str | None:
|
|
109
|
+
"""Retrieve the value for *key* or None."""
|
|
110
|
+
if self._use_keyring:
|
|
111
|
+
import keyring as _keyring
|
|
112
|
+
|
|
113
|
+
return _keyring.get_password(self.service, key)
|
|
114
|
+
return self._file_load().get(key)
|
|
115
|
+
|
|
116
|
+
def delete(self, key: str) -> None:
|
|
117
|
+
"""Remove *key* from storage."""
|
|
118
|
+
if self._use_keyring:
|
|
119
|
+
import keyring as _keyring
|
|
120
|
+
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
_keyring.delete_password(self.service, key)
|
|
123
|
+
else:
|
|
124
|
+
data = self._file_load()
|
|
125
|
+
data.pop(key, None)
|
|
126
|
+
self._file_save(data)
|
|
127
|
+
|
|
128
|
+
def clear_alias(self, alias: str) -> None:
|
|
129
|
+
"""Delete all keys scoped to *alias*."""
|
|
130
|
+
for suffix in ("password", "client_secret"):
|
|
131
|
+
self.delete(f"{alias}:{suffix}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── OAuth bearer auth ─────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
class _BearerAuth(AuthBase):
|
|
137
|
+
"""Attaches an OAuth 2.0 Bearer token to every request."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, token: str) -> None:
|
|
140
|
+
self._token = token
|
|
141
|
+
|
|
142
|
+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
143
|
+
r.headers["Authorization"] = f"Bearer {self._token}"
|
|
144
|
+
return r
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Auth config dataclass ─────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class AuthConfig:
|
|
151
|
+
"""Immutable authentication configuration for a single SF tenant.
|
|
152
|
+
|
|
153
|
+
Fields map cleanly onto the form fields used across your Flask UIs.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
base_url: str
|
|
157
|
+
company_id: str = ""
|
|
158
|
+
auth_type: str = "basic" # basic | oauth2 | certificate
|
|
159
|
+
username: str = ""
|
|
160
|
+
password: str = ""
|
|
161
|
+
client_id: str = ""
|
|
162
|
+
client_secret: str = ""
|
|
163
|
+
token_url: str = ""
|
|
164
|
+
cert_path: str = ""
|
|
165
|
+
key_path: str = ""
|
|
166
|
+
timeout_sec: int = 30
|
|
167
|
+
|
|
168
|
+
# Optional store for persisting / retrieving secrets
|
|
169
|
+
store: CredentialStore = field(default_factory=CredentialStore)
|
|
170
|
+
|
|
171
|
+
# ── Validation ─────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def validate(self) -> None:
|
|
174
|
+
"""Raise AuthError if required fields are missing."""
|
|
175
|
+
if not self.base_url.startswith(("https://", "http://")):
|
|
176
|
+
raise AuthError(
|
|
177
|
+
"base_url must start with https:// or http://",
|
|
178
|
+
details=f"got: {self.base_url}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if self.auth_type == "basic":
|
|
182
|
+
if not self.username:
|
|
183
|
+
raise AuthError("username is required for basic auth")
|
|
184
|
+
if not self.password:
|
|
185
|
+
raise AuthError("password is required for basic auth")
|
|
186
|
+
|
|
187
|
+
elif self.auth_type == "oauth2":
|
|
188
|
+
if not self.client_id:
|
|
189
|
+
raise AuthError("client_id is required for OAuth 2.0")
|
|
190
|
+
if not self.client_secret:
|
|
191
|
+
raise AuthError("client_secret is required for OAuth 2.0")
|
|
192
|
+
if not self.company_id:
|
|
193
|
+
raise AuthError("company_id is required for OAuth 2.0")
|
|
194
|
+
if not self.token_url:
|
|
195
|
+
# Auto-derive from base_url
|
|
196
|
+
parsed = urllib.parse.urlparse(self.base_url)
|
|
197
|
+
self.token_url = f"{parsed.scheme}://{parsed.netloc}/oauth/token"
|
|
198
|
+
logger.debug("Auto-derived token_url: %s", self.token_url)
|
|
199
|
+
|
|
200
|
+
elif self.auth_type == "certificate":
|
|
201
|
+
if not self.cert_path or not Path(self.cert_path).is_file():
|
|
202
|
+
raise AuthError(
|
|
203
|
+
f"cert_path does not exist: {self.cert_path}"
|
|
204
|
+
)
|
|
205
|
+
if not self.key_path or not Path(self.key_path).is_file():
|
|
206
|
+
raise AuthError(
|
|
207
|
+
f"key_path does not exist: {self.key_path}"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
raise AuthError(
|
|
211
|
+
f"Unknown auth_type '{self.auth_type}'. "
|
|
212
|
+
"Must be one of: basic, oauth2, certificate"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# ── Persistence helpers (store / load via keyring/file) ────────────
|
|
216
|
+
|
|
217
|
+
def save_secrets(self) -> None:
|
|
218
|
+
"""Persist password or client_secret for this config's base_url."""
|
|
219
|
+
alias = self._alias()
|
|
220
|
+
if self.auth_type == "basic" and self.password:
|
|
221
|
+
self.store.set(f"{alias}:password", self.password)
|
|
222
|
+
elif self.auth_type == "oauth2" and self.client_secret:
|
|
223
|
+
self.store.set(f"{alias}:client_secret", self.client_secret)
|
|
224
|
+
|
|
225
|
+
def load_secrets(self) -> None:
|
|
226
|
+
"""Hydrate password / client_secret from the credential store."""
|
|
227
|
+
alias = self._alias()
|
|
228
|
+
if self.auth_type == "basic":
|
|
229
|
+
stored = self.store.get(f"{alias}:password")
|
|
230
|
+
if stored:
|
|
231
|
+
self.password = stored
|
|
232
|
+
elif self.auth_type == "oauth2":
|
|
233
|
+
stored = self.store.get(f"{alias}:client_secret")
|
|
234
|
+
if stored:
|
|
235
|
+
self.client_secret = stored
|
|
236
|
+
|
|
237
|
+
def _alias(self) -> str:
|
|
238
|
+
"""Derive a short alias from base_url for keyring keys."""
|
|
239
|
+
parsed = urllib.parse.urlparse(self.base_url)
|
|
240
|
+
return parsed.netloc.replace(":", "_")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── Auth builder classes ──────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
class BasicAuth:
|
|
246
|
+
"""Builds an HTTPBasicAuth instance + headers from an AuthConfig."""
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def build(config: AuthConfig) -> HTTPBasicAuth:
|
|
250
|
+
config.validate()
|
|
251
|
+
# SuccessFactors convention: username@company_id
|
|
252
|
+
if config.company_id and "@" not in config.username:
|
|
253
|
+
user = f"{config.username}@{config.company_id}"
|
|
254
|
+
else:
|
|
255
|
+
user = config.username
|
|
256
|
+
logger.debug("Building BasicAuth for user=%s", user.split("@")[0])
|
|
257
|
+
return HTTPBasicAuth(user, config.password)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class OAuth2Auth:
|
|
261
|
+
"""Fetches an OAuth 2.0 access token via client-credentials grant."""
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def fetch_token(config: AuthConfig) -> str:
|
|
265
|
+
config.validate()
|
|
266
|
+
payload = urllib.parse.urlencode(
|
|
267
|
+
{
|
|
268
|
+
"grant_type": "client_credentials",
|
|
269
|
+
"client_id": config.client_id,
|
|
270
|
+
"client_secret": config.client_secret,
|
|
271
|
+
"company_id": config.company_id,
|
|
272
|
+
}
|
|
273
|
+
).encode()
|
|
274
|
+
|
|
275
|
+
req = urllib.request.Request(
|
|
276
|
+
config.token_url, data=payload, method="POST"
|
|
277
|
+
)
|
|
278
|
+
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
with urllib.request.urlopen(req, timeout=config.timeout_sec) as resp:
|
|
282
|
+
data = json.loads(resp.read())
|
|
283
|
+
except urllib.error.HTTPError as exc:
|
|
284
|
+
body = exc.read().decode("utf-8", errors="replace")[:500]
|
|
285
|
+
raise AuthError(
|
|
286
|
+
f"OAuth token request failed: HTTP {exc.code}",
|
|
287
|
+
details=body,
|
|
288
|
+
) from exc
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
raise AuthError(f"OAuth token request failed: {exc}") from exc
|
|
291
|
+
|
|
292
|
+
token = data.get("access_token")
|
|
293
|
+
if not token:
|
|
294
|
+
raise AuthError(
|
|
295
|
+
"OAuth response missing access_token",
|
|
296
|
+
details=str(data)[:500],
|
|
297
|
+
)
|
|
298
|
+
logger.debug("OAuth token obtained from %s", config.token_url)
|
|
299
|
+
return token
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def build(config: AuthConfig) -> _BearerAuth:
|
|
303
|
+
token = OAuth2Auth.fetch_token(config)
|
|
304
|
+
return _BearerAuth(token)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class CertificateAuth:
|
|
308
|
+
"""Validates that cert/key files exist. The actual cert tuple is
|
|
309
|
+
passed directly to requests.Session.cert."""
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def build(config: AuthConfig) -> tuple[str, str]:
|
|
313
|
+
config.validate()
|
|
314
|
+
return (config.cert_path, config.key_path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Unified auth builder ──────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def build_requests_auth(config: AuthConfig) -> tuple[Any, Any]:
|
|
320
|
+
"""Return (auth_object, cert_tuple) compatible with requests.Session.
|
|
321
|
+
|
|
322
|
+
For basic → (HTTPBasicAuth, None)
|
|
323
|
+
For oauth2 → (_BearerAuth, None)
|
|
324
|
+
For cert → (None, (cert_path, key_path))
|
|
325
|
+
"""
|
|
326
|
+
if config.auth_type == "basic":
|
|
327
|
+
return BasicAuth.build(config), None
|
|
328
|
+
elif config.auth_type == "oauth2":
|
|
329
|
+
return OAuth2Auth.build(config), None
|
|
330
|
+
elif config.auth_type == "certificate":
|
|
331
|
+
return None, CertificateAuth.build(config)
|
|
332
|
+
else:
|
|
333
|
+
raise AuthError(f"Unsupported auth_type: {config.auth_type}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_auth_headers(config: AuthConfig) -> dict[str, str]:
|
|
337
|
+
"""Return a dict of Authorization headers for urllib-based callers.
|
|
338
|
+
|
|
339
|
+
This is used by tools that prefer urllib over requests (e.g. sf-audit).
|
|
340
|
+
"""
|
|
341
|
+
if config.auth_type == "basic":
|
|
342
|
+
config.validate()
|
|
343
|
+
if config.company_id and "@" not in config.username:
|
|
344
|
+
credential_str = f"{config.username}@{config.company_id}:{config.password}"
|
|
345
|
+
else:
|
|
346
|
+
credential_str = f"{config.username}:{config.password}"
|
|
347
|
+
encoded = base64.b64encode(credential_str.encode("utf-8")).decode("utf-8")
|
|
348
|
+
return {"Authorization": f"Basic {encoded}"}
|
|
349
|
+
|
|
350
|
+
elif config.auth_type == "oauth2":
|
|
351
|
+
token = OAuth2Auth.fetch_token(config)
|
|
352
|
+
return {"Authorization": f"Bearer {token}"}
|
|
353
|
+
|
|
354
|
+
else:
|
|
355
|
+
raise AuthError(
|
|
356
|
+
f"build_auth_headers does not support auth_type={config.auth_type}"
|
|
357
|
+
)
|