feat(apps)!: typed BigQuery WIF credentials + load()/update() for app-workflow updates - #1011
Open
Aryamanz29 wants to merge 8 commits into
Open
feat(apps)!: typed BigQuery WIF credentials + load()/update() for app-workflow updates#1011Aryamanz29 wants to merge 8 commits into
Aryamanz29 wants to merge 8 commits into
Conversation
Aryamanz29
force-pushed
the
aryaman/bigquery-wif-typed
branch
from
August 18, 2026 11:26
a4c700a to
2e82483
Compare
BREAKING CHANGE: workload_identity_federation() now requires service_account_email, wif_pool_provider_id, atlan_oauth_id and atlan_oauth_secret as typed keyword-only parameters. Previously only project_id was typed and the rest were funnelled through **extra — undiscoverable, and a misspelled key was silently accepted, producing a broken gcp-wif credential with no error. A missing/misspelled field is now a TypeError. **extra is retained for forward-compatibility with fields newer than this signature. Matches the fully-typed sibling service_account(). Also preserve hand-written app tests (test_bigquery_crawler.py) across generate_apps regeneration, mirroring how hand-written modules are kept.
app.update() is a full replace with no read-back, so callers had to hand-build
the entire input set — and silently dropping a server-derived field (notably
connection_qualified_name, which create injects but update does not) makes the
update return 200 while the next run fails downstream.
AppBuilder now offers the same fluent shape as create for updates:
BigqueryCrawler(client).load(slug).include({...}).update()
load() seeds the builder from the workflow's current inputs (connection,
existing credential, hidden defaults, all other fields); update() full-replaces
while re-injecting connection_qualified_name, normalizing string-typed contract
fields (e.g. control_config returned as an object), and referencing the existing
credential by guid — never re-vaulting or rotating it. Generic on the base, so
every app builder inherits it.
Aryamanz29
force-pushed
the
aryaman/bigquery-wif-typed
branch
from
August 19, 2026 04:34
0bfe236 to
ddb273d
Compare
…s, 1003 retry - generic across every builder (load().update() re-injects connection_qualified_name, references the existing credential, targets the right slug/entrypoint) - multiple typed field changes in one update (include/exclude/regex/toggles) apply while the rest is preserved and runtime keys are dropped - update() falls back to the default entrypoint on a 1003 (no registered contract)
…evel (CONNECT-843)
When an app builder references an EXISTING connection by qualifiedName and
reuses an already-vaulted credential guid (miners, or any builder given
.credential_guid() together with .connection(qualified_name=...)), pyatlan put
that guid in the top-level `credential_guid` field with no credential body.
The create endpoint's credential resolver treats a non-empty top-level
credential_guid as "reuse this guid", but only builds a credential body from a
payload field carrying a non-empty authType. Given a guid and no body it still
runs UpsertCredentialConfig(guid, {"credentialSource": "direct"}), replacing
that credential's shared config record with that single key. Every workflow
sharing the guid then loses authType/host/extra, so auth-type-sensitive
connectors (e.g. gcp-wif) fail on their next run.
Route the reused guid to connection.attributes.defaultCredentialGuid and keep
credential_guid "", which is exactly the shape the UI sends for reuse flows, so
the resolver takes its "no guid" path and never touches the record. Staged raw
credentials, new-connection creation with an explicit guid, and the agent/SDR
path are all unchanged (verified byte-for-byte on the assembled payload).
Introduced by 512fb39, which added the QN credential auto-resolution; the
commit 27 minutes before it (d58bf35) deliberately sent credential_guid="".
This removes pyatlan as a trigger, not the defect itself. The weak guard is
heracles handler/native_app.go:635 (and handler/workflow.go:2959) and should
match the already-correct update path at native_app.go:1020,
`credBodyForUpsert != nil && credentialGUID != ""`. Any other bare-guid caller
still re-corrupts the record.
tests/unit/test_app_builders.py::test_miner_auto_resolves_connection_credential
is updated rather than left alone: it was added by 512fb39, the same commit
that introduced the defect, so its top-level-guid assertion locked in the
regression instead of protecting intended behaviour. It still proves the
connection is looked up and its guid reused, now asserted at the correct
location, and gained an assertion rather than losing one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ONNECT-843) After #1007 a reused credential guid rides on connection.attributes.defaultCredentialGuid with top-level credential_guid "". load() now reads the guid from either place so load().update() never drops the credential reference; update() re-routes it onto the connection via the same reuse guard. Tests assert the connection-placement shape.
Standard Lineage (cross-connection lineage) had no typed builder, so callers had
to hand-build the raw `inputs` dict for `client.app.create/update`. Two details
make that unreasonable to ask of a caller, and neither is expressible from a UI
configmap — hence hand-written, and added to the generator's _HAND_WRITTEN set:
1. `cross_connection_qualified_names` is declared `str` in the app's input
contract but means a LIST of connection qualified names. Sending a native list
fails validateInputsAgainstContract server-side; it has to be json.dumps'd, and
Heracles parses it back into a list for the manifest placeholder. `connections()`
takes a List[str] and encodes it.
2. The defining operation is re-scoping an EXISTING workflow — adding a connection
as it is onboarded — which is an update against a slug. No generated builder
does updates; AppBuilder only has create()/run().
The re-scope path reads before it writes, and that is load-bearing rather than a
convenience: `client.app.update` is a full replace, and the workflow's own
connection entity is republished by the DAG's create-connection node on every run,
so sending a rebuilt or partial connection would overwrite the real one in Atlan
and strip its name and admins. `set_connections` therefore carries the persisted
`connection` and `run_role` over verbatim, read from `client.app.get(slug).dag`
(AppSummary tolerates unmodelled fields, so the DAG arrives as an extra).
Surface:
StandardLineage(client).connection(name=...).connections([...]).run() # create
StandardLineage(client).add_connections(slug, [...]) # onboard
StandardLineage(client).remove_connections(slug, [...]) # hand back
StandardLineage(client).set_connections(slug, [...]) # replace
StandardLineage(client).get_connections(slug) # read
add_connections/remove_connections are idempotent and return None without
publishing a version when nothing would change, so an onboarding portal can replay
safely. Validation is client-side where the error is actionable: the app requires a
non-empty, same-connector scope, and passing the workflow's OWN standard-lineage
connection as its scope — a natural mistake, since both are "connections" — is
rejected with a message that says which is wanted.
Note the two distinct connectors: the workflow's own connection is minted under
`standard-lineage` (_CONNECTOR_NAME), while the `connector` input names the
connector of the connections in scope and is derived from them.
Verified end-to-end against a live tenant (create path deliberately not exercised
there — it would mint a workflow and a connection): the read, add, remove,
idempotent no-op and empty-scope refusal all behave as specified, and the
re-rendered DAG came back with all 14 connection attributes intact, both
downstream nodes identical, and the Temporal workflow type unchanged.
22 new tests; app suite 535 passed, full unit suite 7110 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: mitshah-atlan <mit.shah@atlan.com>
Applies the code-comments guidance to this branch's own diff only. The builder itself needed no tightening -- it already carries contract-level docstrings with no drift hazards and matches the conventions of its neighbours in pyatlan/model/apps/. The one real gap was the opposite of over-writing: the test file had no file header at all. Matches test_asset_export_flows_handwritten.py, the closest analogue in the repo (hand-written tests for a hand-written v3 builder): records that the file survives the generator's regen because it carries no AUTO-GENERATED banner, and why most cases pin the re-scope path rather than create -- client.app.update is a full replace, so what is absent from the payload matters as much as what is present. 22 tests pass; ruff format and check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: mitshah-atlan <mit.shah@atlan.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related improvements to the BigQuery crawler app builder (and, for the second, all app builders). Validated live on an internal tenant with real WIF credentials — a real crawl runs to
Succeededon both create and update+rerun.1. Type the WIF credential (breaking)
workload_identity_federation()typed onlyproject_id; the four WIF-specific values rode through untyped**extra, so they were undiscoverable and a misspelled key was silently accepted (a brokengcp-wifcredential, no error). They're now typed, required keyword-only params mapping to the exactextrakeys, matching the fully-typed siblingservice_account():BREAKING (a bare
project_id-only call now raises) → warrants the next major.**extrais retained for forward-compat with fields newer than this signature.2.
load()/update()— a fluent update path for app workflowsclient.app.update()is a full replace with no read-back, so callers had to hand-build the entire input set. Worse, the server's CREATE path derivesconnection_qualified_namebut UPDATE does not — so a hand-built update returns200while the next run fails downstream (e.g. Publish-to-Atlas).AppBuildernow offers the same shape as create, for updates:load()seeds the builder from the workflow's current inputs;update()full-replaces while:connection_qualified_name(the read-back drops it; the run needs it),control_config),It's on the base
AppBuilder, so every app builder inherits it (Snowflake, Postgres, …), not just BigQuery.Builds on #1007 (CONNECT-843): reused-credential placement
load()/update()and the miner builders reuse an already-vaulted credential. #1007 fixes where that reused guid travels; this PR is stacked on it and adds the read-back half inload().A reused guid must ride on the connection entity (
attributes.defaultCredentialGuid, the UI's wire shape) with top-levelcredential_guidleft"". A bare top-level guid with no credential body makes the CREATE/UPDATE endpoint rewrite that credential's shared config record from the (absent) body — flattening agcp-wifcredential to{"credentialSource":"direct"}and wiping itsauthType/extra.*. Every workflow sharing that guid then breaks: the BigQuery miner preflight resolves the credential as a service account and raisesMissingCredentialsErroron a WIF connection whose crawler succeeds (reproduced end-to-end — fresh crawler runs toSucceeded, then the miner fails preflight on the same credential; fixed once the guid rides on the connection).Before (top-level — corrupts the shared credential):
After (rides on the connection — stored credential untouched):
The read-back half added here:
load()reads the reused guid fromattributes.defaultCredentialGuid(falling back to top-level for older workflows), soload().update()never drops the credential reference. The Update payload below reflects this fixed shape.SDK snippets and generated payloads
Each operation — the SDK call and the exact request it produces (endpoint + body). Offline; placeholder credentials, slugs, and guids.
Create (create without running)
SDK
Request —
POST /api/service/v1/app{ "app_id": "bigquery-crawler", "entrypoint": "crawler", "name": "prod-bigquery", "run": false, "inputs": { "connection": { "typeName": "Connection", "attributes": { "qualifiedName": "default/bigquery/0", "connectorName": "bigquery", "name": "prod-bigquery", "adminRoles": [ "<admin-role-guid>" ] } }, "extraction_method": "direct", "credential_guid": "", "include_filter": "{\"^my-project$\": [\"^analytics$\", \"^sales$\"]}", "exclude_filter": "{}", "temp_table_regex": "", "enable_nested_columns": true, "enable_bigquery_tag_sync": false, "filter_sharded_tables": true, "hidden_datasets": false, "control_config_strategy": "default", "control_config": "{}", "preflight_check": "", "max_concurrent_activities": 15, "max_activities_per_execution": 300, "extract_output_chuck_size": 50000, "credential": { "authType": "gcp-wif", "name": "default-bigquery-0-0", "connectorConfigName": "atlan-connectors-bigquery", "connectorType": "bigquery", "host": "https://bigquery.googleapis.com", "port": 443, "extra": { "project_id": "my-project", "connect_type": "public", "service_account_email": "svc@my-project.iam.gserviceaccount.com", "wif_pool_provider_id": "projects/1/locations/global/workloadIdentityPools/p/providers/pr", "atlan_oauth_id": "oauth-client-id", "atlan_oauth_secret": "<OAUTH_SECRET>" } }, "mapping_chunk_size": 1000, "atlas_auth_type": "internal", "list_datasets_per_chunk": 50 } }Run (create + submit a run)
SDK
Request —
POST /api/service/v1/app{ "app_id": "bigquery-crawler", "entrypoint": "crawler", "name": "prod-bigquery", "run": true, "inputs": { "connection": { "typeName": "Connection", "attributes": { "qualifiedName": "default/bigquery/0", "connectorName": "bigquery", "name": "prod-bigquery", "adminRoles": [ "<admin-role-guid>" ] } }, "extraction_method": "direct", "credential_guid": "", "include_filter": "{\"^my-project$\": [\"^analytics$\", \"^sales$\"]}", "exclude_filter": "{}", "temp_table_regex": "", "enable_nested_columns": true, "enable_bigquery_tag_sync": false, "filter_sharded_tables": true, "hidden_datasets": false, "control_config_strategy": "default", "control_config": "{}", "preflight_check": "", "max_concurrent_activities": 15, "max_activities_per_execution": 300, "extract_output_chuck_size": 50000, "credential": { "authType": "gcp-wif", "name": "default-bigquery-0-0", "connectorConfigName": "atlan-connectors-bigquery", "connectorType": "bigquery", "host": "https://bigquery.googleapis.com", "port": 443, "extra": { "project_id": "my-project", "connect_type": "public", "service_account_email": "svc@my-project.iam.gserviceaccount.com", "wif_pool_provider_id": "projects/1/locations/global/workloadIdentityPools/p/providers/pr", "atlan_oauth_id": "oauth-client-id", "atlan_oauth_secret": "<OAUTH_SECRET>" } }, "mapping_chunk_size": 1000, "atlas_auth_type": "internal", "list_datasets_per_chunk": 50 } }Update (change multiple fields; credential referenced — no rotation)
SDK
( BigqueryCrawler(client).load(slug) .include({"my-project": ["analytics", "sales"]}) # change several fields .exclude({"my-project": ["tmp"]}) .exclude_regex(".*_bak$") .import_nested_columns(False) .update() # credential referenced (no rotation) )Request —
PUT /api/service/v1/app/bigquery-crawler-abc123{ "entrypoint": "crawler", "inputs": { "connection": { "typeName": "Connection", "attributes": { "qualifiedName": "default/bigquery/1700000000", "connectorName": "bigquery", "defaultCredentialGuid": "<existing-cred-guid>", "name": "prod-bigquery", "adminRoles": [ "<admin-role-guid>" ] } }, "extraction_method": "direct", "credential_guid": "", "include_filter": "{\"^my-project$\": [\"^analytics$\", \"^sales$\"]}", "exclude_filter": "{\"^my-project$\": [\"^tmp$\"]}", "temp_table_regex": ".*_bak$", "enable_nested_columns": false, "enable_bigquery_tag_sync": false, "filter_sharded_tables": true, "hidden_datasets": false, "control_config_strategy": "default", "control_config": "{}", "preflight_check": "", "max_concurrent_activities": 15, "max_activities_per_execution": 300, "extract_output_chuck_size": 50000, "app_name": "bigquery-crawler", "atlas_auth_type": "internal", "list_datasets_per_chunk": 50, "mapping_chunk_size": 1000, "connection_qualified_name": "default/bigquery/1700000000" } }Rerun the current published version
SDK
Request —
POST /api/service/v1/app/{slug}/submit(no request body)
Run status (poll until terminal)
SDK
Request —
GET /api/service/v1/app/runs/{run_id}(no request body)
Cancel an in-flight run
SDK
Request —
POST /api/service/v1/app/runs/{run_id}/cancel(no request body)
Add a schedule
SDK
Request —
POST /api/service/v1/app/{slug}/schedule{ "cron": "0 9 * * *", "timezone": "UTC" }Remove a schedule
SDK
Request —
DELETE /api/service/v1/app/{slug}/schedule/{trigger_id}(no request body)
Get a workflow
SDK
Request —
GET /api/service/v1/app/{slug}(no request body)
List / resolve slug by name
SDK
Request —
GET /api/service/v1/app?limit={n}&cursor={c}&name={name}(no request body)
Delete a workflow
SDK
Request —
DELETE /api/service/v1/app/{slug}(no request body)
Describe an app
SDK
Request —
GET /api/service/v1/apps/bigquery-crawler(no request body)
Input contract
SDK
Request —
GET /api/service/v1/apps/bigquery-crawler/inputs?entrypoint=crawler(no request body)
Tests
tests/unit/apps/test_bigquery_crawler.py— WIF typing: keys land in the credential, missing/typo field →TypeError.test_app_builders.py:load().include().update()re-injectsconnection_qualified_name, references the credential on the connection (defaultCredentialGuid, top-levelcredential_guid""; no rotation), normalizescontrol_config, drops runtime keys, preserves the rest;update()beforeload()raises.load()reads the reused guid back fromdefaultCredentialGuid+ slug/entrypoint).update()1003 fallback — retries at the default entrypoint when the named one has no registered contract.version.txt/HISTORY.mdbump — versions are cut via the[release]flow.