Skip to content

Commit 4967e08

Browse files
committed
feat(spark): add Databricks OIDC connector authentication
Enable connectors to exchange their projected workload JWT for short-lived Databricks tokens without adding namespace discovery or Kubernetes RBAC requirements. Signed-off-by: Rohit Sharma <rohitrsh@gmail.com>
1 parent 91f3e80 commit 4967e08

6 files changed

Lines changed: 352 additions & 3 deletions

File tree

plugins/flytekit-spark/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,30 @@ stringData:
5151
The namespace Secret takes precedence over connector-level credentials.
5252
`get` and `delete` operations cache short-lived OAuth tokens and retry once
5353
with a refreshed token when the Databricks API returns HTTP 401.
54+
55+
### OIDC workload identity federation
56+
57+
The connector can exchange its own projected workload JWT for a short-lived
58+
Databricks token without storing a client secret:
59+
60+
```yaml
61+
env:
62+
- name: FLYTE_DATABRICKS_AUTH_TYPE
63+
value: oidc_federation
64+
- name: DATABRICKS_CLIENT_ID
65+
value: "<service-principal-client-id>"
66+
- name: FLYTE_DATABRICKS_OIDC_TOKEN_FILE
67+
value: /var/run/secrets/databricks/token
68+
```
69+
70+
The token file is resolved in this order:
71+
72+
1. `databricks_oidc_token_file` task override or
73+
`FLYTE_DATABRICKS_OIDC_TOKEN_FILE`
74+
2. `AWS_WEB_IDENTITY_TOKEN_FILE`
75+
3. `/var/run/secrets/databricks/token`
76+
77+
The connector deployment is responsible for projecting a JWT at one of these
78+
paths and configuring a matching federation policy for the Databricks service
79+
principal. PAT remains the default unless `oidc_federation` is selected
80+
explicitly.

plugins/flytekit-spark/flytekitplugins/spark/connector.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class DatabricksJobMetadata(ResourceMeta):
4242
auth_type: Optional[str] = None
4343
client_id: Optional[str] = None
4444
oauth_secret_name: Optional[str] = None
45+
oidc_token_file: Optional[str] = None
46+
oidc_audience: Optional[str] = None
4547
namespace: Optional[str] = None
4648

4749

@@ -312,6 +314,8 @@ async def create(
312314
auth_type=auth.auth_type,
313315
client_id=auth.settings.client_id,
314316
oauth_secret_name=auth.settings.oauth_secret_name,
317+
oidc_token_file=(auth.settings.oidc_token_file if auth.auth_type == "oidc_federation" else None),
318+
oidc_audience=(auth.settings.oidc_audience if auth.auth_type == "oidc_federation" else None),
315319
namespace=namespace,
316320
)
317321

@@ -379,13 +383,15 @@ async def _request_with_auth(
379383
from .databricks_auth import DatabricksAuthError, build_auth
380384

381385
auth = None
382-
if resource_meta.auth_type == "oauth_m2m":
386+
if resource_meta.auth_type in {"oauth_m2m", "oidc_federation"}:
383387
auth = build_auth(
384388
workspace_url=resource_meta.databricks_instance,
385389
auth_type=resource_meta.auth_type,
386390
namespace=resource_meta.namespace,
387391
client_id=resource_meta.client_id,
388392
oauth_secret_name=resource_meta.oauth_secret_name,
393+
oidc_token_file=resource_meta.oidc_token_file,
394+
oidc_audience=resource_meta.oidc_audience,
389395
)
390396

391397
token = resource_meta.auth_token

plugins/flytekit-spark/flytekitplugins/spark/databricks_auth.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,19 @@
2424

2525
FLYTE_DATABRICKS_AUTH_TYPE_ENV = "FLYTE_DATABRICKS_AUTH_TYPE"
2626
FLYTE_DATABRICKS_OAUTH_SECRET_NAME_ENV = "FLYTE_DATABRICKS_OAUTH_SECRET_NAME"
27+
FLYTE_DATABRICKS_OIDC_TOKEN_FILE_ENV = "FLYTE_DATABRICKS_OIDC_TOKEN_FILE"
28+
FLYTE_DATABRICKS_OIDC_AUDIENCE_ENV = "FLYTE_DATABRICKS_OIDC_AUDIENCE"
2729
DATABRICKS_CLIENT_ID_ENV = "DATABRICKS_CLIENT_ID"
2830
DATABRICKS_CLIENT_SECRET_ENV = "DATABRICKS_CLIENT_SECRET"
31+
AWS_WEB_IDENTITY_TOKEN_FILE_ENV = "AWS_WEB_IDENTITY_TOKEN_FILE"
2932

3033
DEFAULT_OAUTH_SECRET_NAME = "databricks-oauth"
34+
DEFAULT_OIDC_AUDIENCE = "databricks"
35+
DEFAULT_PROJECTED_SA_TOKEN_PATH = "/var/run/secrets/databricks/token"
3136
TOKEN_REFRESH_BUFFER_SECONDS = 60
3237
TOKEN_ENDPOINT_MAX_RETRIES = 3
3338
TOKEN_ENDPOINT_BACKOFF_BASE_SECONDS = 0.2
34-
VALID_AUTH_TYPES = {"pat", "oauth_m2m"}
39+
VALID_AUTH_TYPES = {"pat", "oauth_m2m", "oidc_federation"}
3540

3641

3742
class DatabricksAuthError(Exception):
@@ -47,6 +52,8 @@ class _Settings:
4752
client_id: Optional[str]
4853
oauth_secret_name: str
4954
token_secret_name: Optional[str]
55+
oidc_token_file: Optional[str]
56+
oidc_audience: str
5057
namespace: Optional[str]
5158

5259
@staticmethod
@@ -74,10 +81,30 @@ def _pick(task_key: str, env_key: Optional[str], default: Optional[str] = None)
7481
)
7582
or DEFAULT_OAUTH_SECRET_NAME,
7683
token_secret_name=custom.get("databricksTokenSecret"),
84+
oidc_token_file=_pick(
85+
"databricksOidcTokenFile",
86+
FLYTE_DATABRICKS_OIDC_TOKEN_FILE_ENV,
87+
),
88+
oidc_audience=_pick(
89+
"databricksOidcAudience",
90+
FLYTE_DATABRICKS_OIDC_AUDIENCE_ENV,
91+
DEFAULT_OIDC_AUDIENCE,
92+
)
93+
or DEFAULT_OIDC_AUDIENCE,
7794
namespace=namespace,
7895
)
7996

8097

98+
def _resolve_oidc_token_file(settings: _Settings) -> Optional[str]:
99+
"""Resolve the first configured projected JWT file that exists."""
100+
candidates = (
101+
settings.oidc_token_file,
102+
os.getenv(AWS_WEB_IDENTITY_TOKEN_FILE_ENV),
103+
DEFAULT_PROJECTED_SA_TOKEN_PATH,
104+
)
105+
return next((path for path in candidates if path and os.path.exists(path)), None)
106+
107+
81108
@dataclass
82109
class _CachedToken:
83110
access_token: str
@@ -280,6 +307,74 @@ async def invalidate_cache(self) -> None:
280307
await _TOKEN_CACHE.invalidate((self.workspace_url, client_id, self.settings.namespace or "_"))
281308

282309

310+
class OIDCConnectorAuth(DatabricksAuth):
311+
"""Exchange the connector workload's projected JWT for a Databricks token."""
312+
313+
auth_type = "oidc_federation"
314+
strategy_name = "OIDCConnectorAuth"
315+
316+
def _client_id(self) -> str:
317+
client_id = self.settings.client_id or os.getenv(DATABRICKS_CLIENT_ID_ENV)
318+
if not client_id:
319+
raise DatabricksAuthError(
320+
"OIDC federation requires a client ID. Configure databricks_client_id " "or DATABRICKS_CLIENT_ID."
321+
)
322+
return client_id
323+
324+
def _subject_token_file(self) -> str:
325+
token_file = _resolve_oidc_token_file(self.settings)
326+
if not token_file:
327+
raise DatabricksAuthError(
328+
"OIDC federation requires a projected JWT file. Configure "
329+
"databricks_oidc_token_file, FLYTE_DATABRICKS_OIDC_TOKEN_FILE, "
330+
"or AWS_WEB_IDENTITY_TOKEN_FILE."
331+
)
332+
return token_file
333+
334+
def _cache_key(self, client_id: str) -> Tuple[str, str, str]:
335+
return (
336+
self.workspace_url,
337+
client_id,
338+
f"oidc:{self.settings.oidc_audience}",
339+
)
340+
341+
async def get_bearer_token(self, session: "aiohttp.ClientSession") -> str: # type: ignore[name-defined]
342+
client_id = self._client_id()
343+
key = self._cache_key(client_id)
344+
cached = await _TOKEN_CACHE.get(key)
345+
if cached:
346+
return cached
347+
348+
token_file = self._subject_token_file()
349+
try:
350+
with open(token_file) as token_stream:
351+
subject_token = token_stream.read().strip()
352+
except OSError as error:
353+
raise DatabricksAuthError(f"Unable to read projected OIDC token file '{token_file}': {error}") from error
354+
if not subject_token:
355+
raise DatabricksAuthError(f"Projected OIDC token file '{token_file}' is empty")
356+
357+
payload = await _post_token(
358+
session,
359+
self.workspace_url,
360+
{
361+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
362+
"scope": "all-apis",
363+
"client_id": client_id,
364+
"subject_token": subject_token,
365+
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
366+
},
367+
)
368+
access_token = payload.get("access_token")
369+
if not access_token:
370+
raise DatabricksAuthError("Databricks OIDC response did not contain access_token")
371+
await _TOKEN_CACHE.put(key, access_token, int(payload.get("expires_in", 3600)))
372+
return access_token
373+
374+
async def invalidate_cache(self) -> None:
375+
await _TOKEN_CACHE.invalidate(self._cache_key(self._client_id()))
376+
377+
283378
async def select_auth(
284379
task_template: Optional[TaskTemplate],
285380
workspace_url: str,
@@ -294,6 +389,8 @@ async def select_auth(
294389
)
295390
if auth_type == "oauth_m2m":
296391
return OAuthM2MAuth(workspace_url, settings)
392+
if auth_type == "oidc_federation":
393+
return OIDCConnectorAuth(workspace_url, settings)
297394
return PATAuth(workspace_url, settings)
298395

299396

@@ -303,6 +400,8 @@ def build_auth(
303400
namespace: Optional[str] = None,
304401
client_id: Optional[str] = None,
305402
oauth_secret_name: Optional[str] = None,
403+
oidc_token_file: Optional[str] = None,
404+
oidc_audience: Optional[str] = None,
306405
) -> DatabricksAuth:
307406
"""Rebuild an auth strategy from persisted connector metadata."""
308407
settings = _Settings(
@@ -311,12 +410,16 @@ def build_auth(
311410
client_id=client_id,
312411
oauth_secret_name=oauth_secret_name or DEFAULT_OAUTH_SECRET_NAME,
313412
token_secret_name=None,
413+
oidc_token_file=oidc_token_file,
414+
oidc_audience=oidc_audience or DEFAULT_OIDC_AUDIENCE,
314415
namespace=namespace,
315416
)
316417
if auth_type == "oauth_m2m":
317418
return OAuthM2MAuth(workspace_url, settings)
318419
if auth_type == "pat":
319420
return PATAuth(workspace_url, settings)
421+
if auth_type == "oidc_federation":
422+
return OIDCConnectorAuth(workspace_url, settings)
320423
raise DatabricksAuthError(
321424
f"Invalid Databricks auth type '{auth_type}'. Expected one of {sorted(VALID_AUTH_TYPES)}."
322425
)

plugins/flytekit-spark/flytekitplugins/spark/task.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,16 @@ class DatabricksV2(Spark):
9090
databricks_token_secret (Optional[str]): Custom name for the K8s secret containing
9191
the Databricks token. Defaults to 'databricks-token' if not specified.
9292
databricks_auth_type (Optional[str]): Authentication mode. Supported values are
93-
``"pat"`` and ``"oauth_m2m"``. When unset, PAT remains the default.
93+
``"pat"``, ``"oauth_m2m"``, and ``"oidc_federation"``. When unset, PAT
94+
remains the default.
9495
databricks_client_id (Optional[str]): Databricks service-principal client ID used
9596
for OAuth M2M. Falls back to ``DATABRICKS_CLIENT_ID`` on the connector.
9697
databricks_oauth_secret (Optional[str]): Name of the namespace K8s secret containing
9798
``client_id`` and ``client_secret``. Defaults to ``databricks-oauth``.
99+
databricks_oidc_token_file (Optional[str]): Path to the connector workload's
100+
projected OIDC JWT. Falls back to ``AWS_WEB_IDENTITY_TOKEN_FILE``.
101+
databricks_oidc_audience (Optional[str]): Audience associated with the projected
102+
JWT. Defaults to ``databricks``.
98103
notebook_path (Optional[str]): Path to Databricks notebook
99104
(e.g., "/Users/user@example.com/notebook").
100105
notebook_base_parameters (Optional[Dict[str, str]]): Parameters to pass to the notebook.
@@ -208,6 +213,8 @@ class DatabricksV2(Spark):
208213
databricks_auth_type: Optional[str] = None
209214
databricks_client_id: Optional[str] = None
210215
databricks_oauth_secret: Optional[str] = None
216+
databricks_oidc_token_file: Optional[str] = None
217+
databricks_oidc_audience: Optional[str] = None
211218
notebook_path: Optional[str] = None
212219
notebook_base_parameters: Optional[Dict[str, str]] = None
213220

@@ -329,6 +336,10 @@ def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]:
329336
custom_dict["databricksClientId"] = cfg.databricks_client_id
330337
if cfg.databricks_oauth_secret:
331338
custom_dict["databricksOauthSecret"] = cfg.databricks_oauth_secret
339+
if cfg.databricks_oidc_token_file:
340+
custom_dict["databricksOidcTokenFile"] = cfg.databricks_oidc_token_file
341+
if cfg.databricks_oidc_audience:
342+
custom_dict["databricksOidcAudience"] = cfg.databricks_oidc_audience
332343
if cfg.notebook_path:
333344
custom_dict["notebookPath"] = cfg.notebook_path
334345
if cfg.notebook_base_parameters:

0 commit comments

Comments
 (0)