Skip to content

Commit 8388b31

Browse files
authored
SAC-30337: Add dynamic schema workflow with discovery support (#1)
* SAC-30337: Add dynamic schema workflow with discovery support * SAC-30337: Resolve PR code review comments - Fix grammar: 'due permissions' -> 'due to permissions' in sync.py - Fix update_currently_syncing to use state.pop() avoiding null state write - Update type hint to Optional[str] on update_currently_syncing - Use tap_stream_id consistently and add parent-stream fallback in child_map - Pass pre-built child_map to write_schema to avoid O(n) catalog re-traversal - Handle 204 No Content and empty bodies in _make_request to avoid JSON decode errors - Guard int() cast in _get_retry_after with ValueError fallback for HTTP-date headers * Remove parent-filter keys from automatic fields * Update auth mechanism to basic-auth with fallback to OAuth * Update required config keys * Update logging * SAC-30338: Add sync logic for the tap (#2) * SAC-30338: Add sync logic for the tap along with unittests and spikes * Update tests to use unittests * Resolve Copilot comments * Add discovery time stream probe and prune strategy * Remove redundant files and code lines * Integration tests (#3) * Add Integration tests for the tap * Update integration testsand yml file * Resolve Copilot Comments * Add retry on dynamic probe
1 parent 04e001b commit 8388b31

32 files changed

+8848
-14
lines changed

.circleci/config.yml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,12 @@ jobs:
1212
source /usr/local/share/virtualenvs/tap-sap-success-factors/bin/activate
1313
uv pip install -U setuptools
1414
uv pip install .[dev]
15-
- run:
16-
name: "JSON Validator"
17-
command: |
18-
source /usr/local/share/virtualenvs/tap-tester/bin/activate
19-
stitch-validate-json tap_branch/schemas/*.json
2015
- run:
2116
name: "pylint"
2217
command: |
2318
source /usr/local/share/virtualenvs/tap-sap-success-factors/bin/activate
2419
uv pip install pylint
25-
pylint tap_branch -d C,R,W
20+
pylint tap_sap_success_factors -d C,R,W
2621
- add_ssh_keys
2722
- run:
2823
name: "Unit Tests"
@@ -40,7 +35,7 @@ jobs:
4035
command: |
4136
source /usr/local/share/virtualenvs/tap-tester/bin/activate
4237
uv pip install --upgrade awscli
43-
aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
38+
aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/vm dev_env.sh
4439
source dev_env.sh
4540
unset USE_STITCH_BACKEND
4641
run-test --tap=tap-sap-success-factors tests

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,10 @@ Example config:
3434

3535
```json
3636
{
37-
"api_endpoint": "https://<company>.successfactors.com",
38-
"oauth_endpoint": "https://<company>.successfactors.com/oauth/token",
37+
"api_server": "https://<company>.successfactors.com",
3938
"client_id": "...",
4039
"company_id": "...",
4140
"assertion": "...",
42-
"grant_type": "...",
4341
"start_date": "2024-01-01T00:00:00Z",
4442
"request_timeout": 300
4543
}

sample_config.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
{
22
"client_id": "your_client_id",
33
"user_id": "your_user_id",
4-
"oauth_endpoint": "oauth_endpoint_url",
54
"company_id": "your_company_id",
6-
"api_endpoint": "api_endpoint_url",
7-
"grant_type": "grant_type",
5+
"api_server": "api_server_url",
86
"start_date": "2024-01-01T00:00:00Z",
9-
"lookback_window_days": 1,
107
"assertion": "saml_assertion"
118
}

spikes/generate_saml_assertion.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
""" Module to generate a SAML assertion for SuccessFactors API authentication using the OAuth Bearer flow.
2+
Following dependencies are required:
3+
- lxml
4+
- signxml
5+
- cryptography (for signxml)
6+
- python-dotenv (for loading environment variables from a .env file)
7+
Environment variables expected in .env file:
8+
- SF_CLIENT_ID: SuccessFactors API client_id
9+
- SF_USERNAME: SuccessFactors username
10+
- SF_TOKEN_URL: SuccessFactors token endpoint URL (e.g. https://api4.successfactors.com/sf/oauth/token)
11+
- SF_PRIVATE_KEY_FILE: Path to the private key file (PEM format) used for signing the assertion (default: private_key.pem)
12+
- SF_CERT_FILE: Path to the certificate file (PEM format) corresponding to the private key (default: certificate.pem)
13+
14+
Run the script:
15+
- python generate_saml_assertion.py
16+
The output will be a base64 encoded SAML assertion that can be used in the OAuth
17+
Bearer flow to request an access token.
18+
19+
NOTE: This workflow will be accommodated in the SuccessFactors API client library,
20+
so this script is primarily for demonstration and testing purposes.
21+
"""
22+
23+
import base64
24+
import os
25+
import uuid
26+
from datetime import datetime, timedelta, timezone
27+
28+
from dotenv import load_dotenv
29+
from lxml import etree
30+
from signxml import XMLSigner, methods
31+
32+
# ==============================
33+
# CONFIGURATION
34+
# ==============================
35+
36+
load_dotenv()
37+
38+
CLIENT_ID = os.getenv("SF_CLIENT_ID") # SuccessFactors API client_id
39+
USERNAME = os.getenv("SF_USERNAME") # SuccessFactors username
40+
TOKEN_URL = os.getenv("SF_TOKEN_URL") # SuccessFactors token endpoint URL (e.g. https://api4.successfactors.com/sf/oauth/token)
41+
42+
PRIVATE_KEY_FILE = os.getenv("SF_PRIVATE_KEY_FILE", "private_key.pem")
43+
CERT_FILE = os.getenv("SF_CERT_FILE", "certificate.pem")
44+
45+
46+
def generate_saml_assertion():
47+
48+
now = datetime.now(timezone.utc)
49+
issue_instant = now.strftime("%Y-%m-%dT%H:%M:%SZ")
50+
not_on_or_after = (now + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
51+
52+
assertion_id = "_" + str(uuid.uuid4())
53+
54+
NSMAP = {
55+
"saml2": "urn:oasis:names:tc:SAML:2.0:assertion",
56+
"ds": "http://www.w3.org/2000/09/xmldsig#",
57+
"xs": "http://www.w3.org/2001/XMLSchema",
58+
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
59+
}
60+
61+
assertion = etree.Element(
62+
"{urn:oasis:names:tc:SAML:2.0:assertion}Assertion",
63+
nsmap=NSMAP,
64+
ID=assertion_id,
65+
Version="2.0",
66+
IssueInstant=issue_instant,
67+
)
68+
69+
# ----------------------
70+
# Issuer (must be client_id)
71+
# ----------------------
72+
issuer = etree.SubElement(
73+
assertion,
74+
"{urn:oasis:names:tc:SAML:2.0:assertion}Issuer"
75+
)
76+
issuer.text = CLIENT_ID
77+
78+
# ----------------------
79+
# Subject (userId mode)
80+
# ----------------------
81+
subject = etree.SubElement(
82+
assertion,
83+
"{urn:oasis:names:tc:SAML:2.0:assertion}Subject"
84+
)
85+
86+
name_id = etree.SubElement(
87+
subject,
88+
"{urn:oasis:names:tc:SAML:2.0:assertion}NameID",
89+
Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
90+
)
91+
name_id.text = USERNAME
92+
93+
subject_confirmation = etree.SubElement(
94+
subject,
95+
"{urn:oasis:names:tc:SAML:2.0:assertion}SubjectConfirmation",
96+
Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"
97+
)
98+
99+
etree.SubElement(
100+
subject_confirmation,
101+
"{urn:oasis:names:tc:SAML:2.0:assertion}SubjectConfirmationData",
102+
NotOnOrAfter=not_on_or_after,
103+
Recipient=TOKEN_URL,
104+
InResponseTo=CLIENT_ID
105+
)
106+
107+
# ----------------------
108+
# Conditions
109+
# ----------------------
110+
conditions = etree.SubElement(
111+
assertion,
112+
"{urn:oasis:names:tc:SAML:2.0:assertion}Conditions",
113+
NotBefore=issue_instant,
114+
NotOnOrAfter=not_on_or_after,
115+
)
116+
117+
audience_restriction = etree.SubElement(
118+
conditions,
119+
"{urn:oasis:names:tc:SAML:2.0:assertion}AudienceRestriction"
120+
)
121+
122+
audience = etree.SubElement(
123+
audience_restriction,
124+
"{urn:oasis:names:tc:SAML:2.0:assertion}Audience"
125+
)
126+
audience.text = TOKEN_URL
127+
128+
# ----------------------
129+
# Required AttributeStatement
130+
# ----------------------
131+
attribute_statement = etree.SubElement(
132+
assertion,
133+
"{urn:oasis:names:tc:SAML:2.0:assertion}AttributeStatement"
134+
)
135+
136+
def add_attribute(name, value):
137+
attr = etree.SubElement(
138+
attribute_statement,
139+
"{urn:oasis:names:tc:SAML:2.0:assertion}Attribute",
140+
Name=name
141+
)
142+
attr_value = etree.SubElement(
143+
attr,
144+
"{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue"
145+
)
146+
attr_value.set(
147+
"{http://www.w3.org/2001/XMLSchema-instance}type",
148+
"xs:string"
149+
)
150+
attr_value.text = value
151+
152+
add_attribute("api_key", CLIENT_ID)
153+
add_attribute("use_username", "false")
154+
add_attribute("external_user", "false")
155+
156+
# ----------------------
157+
# SIGN ASSERTION (SHA256)
158+
# ----------------------
159+
with open(PRIVATE_KEY_FILE, "rb") as key_file:
160+
private_key = key_file.read()
161+
162+
with open(CERT_FILE, "rb") as cert_file:
163+
cert = cert_file.read()
164+
165+
signer = XMLSigner(
166+
method=methods.enveloped,
167+
signature_algorithm="rsa-sha256",
168+
digest_algorithm="sha256",
169+
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"
170+
)
171+
172+
signed_assertion = signer.sign(
173+
assertion,
174+
key=private_key,
175+
cert=cert,
176+
reference_uri=assertion_id
177+
)
178+
179+
saml_xml = etree.tostring(
180+
signed_assertion,
181+
xml_declaration=True,
182+
encoding="UTF-8"
183+
)
184+
185+
return base64.b64encode(saml_xml).decode("utf-8")
186+
187+
188+
if __name__ == "__main__":
189+
saml_assertion = generate_saml_assertion()
190+
191+
print("\nBase64 Encoded SAML Assertion:\n")
192+
print(saml_assertion)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import json
2+
import sys
3+
4+
import singer
5+
6+
from tap_sap_success_factors.client import SAPSuccessFactorsClient
7+
from tap_sap_success_factors.discover import discover
8+
from tap_sap_success_factors.sync import sync
9+
10+
LOGGER = singer.get_logger()
11+
12+
REQUIRED_CONFIG_KEYS = ["client_id", "user_id", "company_id",
13+
"username", "password", "api_server", "start_date"]
14+
15+
16+
def do_discover(client: SAPSuccessFactorsClient):
17+
"""Discover and emit the catalog."""
18+
LOGGER.info("Starting discover")
19+
catalog = discover(client)
20+
json.dump(catalog.to_dict(), sys.stdout, indent=2)
21+
LOGGER.info("Finished discover")
22+
23+
24+
@singer.utils.handle_top_exception(LOGGER)
25+
def main():
26+
"""Run the tap entrypoint."""
27+
parsed_args = singer.utils.parse_args(REQUIRED_CONFIG_KEYS)
28+
state = parsed_args.state or {}
29+
30+
with SAPSuccessFactorsClient(parsed_args.config) as client:
31+
if parsed_args.discover:
32+
do_discover(client)
33+
elif parsed_args.catalog:
34+
sync(
35+
client=client,
36+
config=parsed_args.config,
37+
catalog=parsed_args.catalog,
38+
state=state,
39+
)
40+
41+
42+
if __name__ == "__main__":
43+
main()

tap_sap_success_factors/auth.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import base64
2+
from typing import Dict, Optional
3+
4+
from singer import get_logger
5+
6+
LOGGER = get_logger()
7+
8+
9+
def build_basic_auth_header(config: Dict) -> Optional[str]:
10+
"""Return a ``Basic <base64>`` authorization header value when the config
11+
contains ``username`` and ``password``.
12+
13+
The encoded token is ``base64(username:password)`` per RFC 7617.
14+
Returns ``None`` when the required keys are absent so callers can fall
15+
back to the OAuth / SAML bearer flow.
16+
"""
17+
username = config.get("username")
18+
password = config.get("password")
19+
if username and password:
20+
token = base64.b64encode(f"{username}:{password}".encode()).decode()
21+
LOGGER.info("Using HTTP Basic authentication mechanism with provided username and password.")
22+
return f"Basic {token}"
23+
return None
24+
25+
26+
def build_token_request(config: Dict) -> Dict:
27+
"""Build OAuth token request payload.
28+
29+
Supported modes:
30+
- static token in `access_token`
31+
- SAML bearer flow with `saml_assertion`
32+
"""
33+
if config.get("access_token"):
34+
return {}
35+
36+
payload = {
37+
"client_id": config.get("client_id"),
38+
"company_id": config.get("company_id"),
39+
"grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer",
40+
"assertion": config.get("assertion") or config.get("saml_assertion"),
41+
}
42+
43+
if config.get("refresh_token"):
44+
payload = {
45+
"client_id": config.get("client_id"),
46+
"grant_type": "refresh_token",
47+
"refresh_token": config.get("refresh_token"),
48+
}
49+
50+
return {k: v for k, v in payload.items() if v is not None}

0 commit comments

Comments
 (0)