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.
@@ -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
+ )