Skip to content

Commit 0d5c0dc

Browse files
authored
Merge pull request #685 from atlanhq/APP-7824
APP-7824: Added support for `AtlanClient` creation via api token `guid`
2 parents 7af2190 + ad43abb commit 0d5c0dc

File tree

3 files changed

+107
-2
lines changed

3 files changed

+107
-2
lines changed

pyatlan/client/atlan.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import copy
88
import json
99
import logging
10+
import os
1011
import shutil
1112
import uuid
1213
from contextvars import ContextVar
@@ -41,7 +42,7 @@
4142
from pyatlan.client.asset import A, AssetClient, IndexSearchResults, LineageListResults
4243
from pyatlan.client.audit import AuditClient
4344
from pyatlan.client.common import CONNECTION_RETRY, HTTP_PREFIX, HTTPS_PREFIX
44-
from pyatlan.client.constants import EVENT_STREAM, PARSE_QUERY, UPLOAD_IMAGE
45+
from pyatlan.client.constants import EVENT_STREAM, GET_TOKEN, PARSE_QUERY, UPLOAD_IMAGE
4546
from pyatlan.client.contract import ContractClient
4647
from pyatlan.client.credential import CredentialClient
4748
from pyatlan.client.file import FileClient
@@ -75,7 +76,7 @@
7576
from pyatlan.model.group import AtlanGroup, CreateGroupResponse, GroupResponse
7677
from pyatlan.model.lineage import LineageListRequest
7778
from pyatlan.model.query import ParsedQuery, QueryParserRequest
78-
from pyatlan.model.response import AssetMutationResponse
79+
from pyatlan.model.response import AccessTokenResponse, AssetMutationResponse
7980
from pyatlan.model.role import RoleResponse
8081
from pyatlan.model.search import IndexSearchRequest
8182
from pyatlan.model.typedef import TypeDef, TypeDefResponse
@@ -346,6 +347,72 @@ def source_tag_cache(self) -> SourceTagCache:
346347
self._source_tag_cache = SourceTagCache(client=self)
347348
return self._source_tag_cache
348349

350+
@classmethod
351+
def from_token_guid(cls, guid: str) -> AtlanClient:
352+
"""
353+
Create an AtlanClient instance using an API token GUID.
354+
355+
This method performs a multi-step authentication flow:
356+
1. Obtains Atlan-Argo (superuser) access token
357+
2. Uses Argo token to retrieve the API token's client credentials
358+
3. Exchanges those credentials for an access token
359+
4. Returns a new AtlanClient authenticated with the resolved token
360+
361+
:param guid: API token GUID to resolve
362+
:returns: a new client instance authenticated with the resolved token
363+
:raises: ErrorCode.UNABLE_TO_ESCALATE_WITH_PARAM: If any step in the token resolution fails
364+
"""
365+
base_url = os.environ.get("ATLAN_BASE_URL", "INTERNAL")
366+
367+
# Step 1: Initialize base client and get Atlan-Argo credentials
368+
# Note: Using empty api_key as we're bootstrapping authentication
369+
client = AtlanClient(base_url=base_url, api_key="")
370+
client_info = client.impersonate._get_client_info()
371+
372+
# Prepare credentials for Atlan-Argo token request
373+
argo_credentials = {
374+
"grant_type": "client_credentials",
375+
"client_id": client_info.client_id,
376+
"client_secret": client_info.client_secret,
377+
"scope": "openid",
378+
}
379+
380+
# Step 2: Obtain Atlan-Argo (superuser) access token
381+
try:
382+
raw_json = client._call_api(GET_TOKEN, request_obj=argo_credentials)
383+
argo_token = AccessTokenResponse(**raw_json).access_token
384+
temp_argo_client = AtlanClient(base_url=base_url, api_key=argo_token)
385+
except AtlanError as atlan_err:
386+
raise ErrorCode.UNABLE_TO_ESCALATE_WITH_PARAM.exception_with_parameters(
387+
"Failed to obtain Atlan-Argo token"
388+
) from atlan_err
389+
390+
# Step 3: Use Argo client to retrieve API token's credentials
391+
# Both endpoints require authentication, hence using the Argo token
392+
token_secret = temp_argo_client.impersonate.get_client_secret(client_guid=guid)
393+
token_client_id = temp_argo_client.token.get_by_guid( # type: ignore[union-attr]
394+
guid=guid
395+
).client_id
396+
397+
# Step 4: Exchange API token credentials for access token
398+
token_credentials = {
399+
"grant_type": "client_credentials",
400+
"client_id": token_client_id,
401+
"client_secret": token_secret,
402+
"scope": "openid",
403+
}
404+
405+
try:
406+
raw_json = client._call_api(GET_TOKEN, request_obj=token_credentials)
407+
token_api_key = AccessTokenResponse(**raw_json).access_token
408+
409+
# Step 5: Create and return the authenticated client
410+
return AtlanClient(base_url=base_url, api_key=token_api_key)
411+
except AtlanError as atlan_err:
412+
raise ErrorCode.UNABLE_TO_ESCALATE_WITH_PARAM.exception_with_parameters(
413+
"Failed to obtain access token for API token"
414+
) from atlan_err
415+
349416
def update_headers(self, header: Dict[str, str]):
350417
self._session.headers.update(header)
351418

pyatlan/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,13 @@ class ErrorCode(Enum):
714714
"Check the details of your configured privileged credentials and the user you requested to impersonate.",
715715
PermissionError,
716716
)
717+
UNABLE_TO_ESCALATE_WITH_PARAM = (
718+
403,
719+
"ATLAN-PYTHON-403-003",
720+
"Unable to escalate to a privileged user: {0}",
721+
"Check the details of your configured privileged credentials.",
722+
PermissionError,
723+
)
717724
NOT_FOUND_PASSTHROUGH = (
718725
404,
719726
"ATLAN-PYTHON-404-000",

tests/integration/test_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,37 @@ def test_client_401_token_refresh(
16181618
assert results and results.count >= 1
16191619

16201620

1621+
def test_client_init_from_token_guid(
1622+
client: AtlanClient, token: ApiToken, argo_fake_token: ApiToken, monkeypatch
1623+
):
1624+
# In real-world scenarios, these values come from environment variables
1625+
# configured at the Argo template level. The SDK uses these values to
1626+
# create a temporary client, which allows us to find the `client_id` and `client_secret`
1627+
# for the provided API token GUID, later used to initialize a client with its actual access token (API key) <- AtlanClient.from_token_guid()
1628+
assert argo_fake_token and argo_fake_token.guid
1629+
argo_client_secret = client.impersonate.get_client_secret(
1630+
client_guid=argo_fake_token.guid
1631+
)
1632+
monkeypatch.setenv("CLIENT_ID", argo_fake_token.client_id)
1633+
monkeypatch.setenv("CLIENT_SECRET", argo_client_secret)
1634+
1635+
# Ensure it's a valid API token
1636+
assert token and token.username and token.guid
1637+
assert "service-account" in token.username
1638+
token_client = AtlanClient.from_token_guid(guid=token.guid)
1639+
1640+
# Should be able to perform all operations
1641+
# with this client as long as it has the necessary permissions
1642+
results = (
1643+
FluentSearch()
1644+
.where(CompoundQuery.active_assets())
1645+
.where(CompoundQuery.asset_type(AtlasGlossary))
1646+
.page_size(100)
1647+
.execute(client=token_client)
1648+
)
1649+
assert results and results.count >= 1
1650+
1651+
16211652
def test_process_assets_when_no_assets_found(client: AtlanClient):
16221653
def should_never_be_called(_: Asset):
16231654
pytest.fail("Should not be called")

0 commit comments

Comments
 (0)