Skip to content

feat(apps)!: typed BigQuery WIF credentials + load()/update() for app-workflow updates - #1011

Open
Aryamanz29 wants to merge 8 commits into
mainfrom
aryaman/bigquery-wif-typed
Open

feat(apps)!: typed BigQuery WIF credentials + load()/update() for app-workflow updates#1011
Aryamanz29 wants to merge 8 commits into
mainfrom
aryaman/bigquery-wif-typed

Conversation

@Aryamanz29

@Aryamanz29 Aryamanz29 commented Aug 18, 2026

Copy link
Copy Markdown
Member

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 Succeeded on both create and update+rerun.

1. Type the WIF credential (breaking)

workload_identity_federation() typed only project_id; the four WIF-specific values rode through untyped **extra, so they were undiscoverable and a misspelled key was silently accepted (a broken gcp-wif credential, no error). They're now typed, required keyword-only params mapping to the exact extra keys, matching the fully-typed sibling service_account():

BigqueryCrawler(client).workload_identity_federation(
    project_id=..., service_account_email=..., wif_pool_provider_id=...,
    atlan_oauth_id=..., atlan_oauth_secret=...,
)

BREAKING (a bare project_id-only call now raises) → warrants the next major. **extra is retained for forward-compat with fields newer than this signature.

2. load() / update() — a fluent update path for app workflows

client.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 derives connection_qualified_name but UPDATE does not — so a hand-built update returns 200 while the next run fails downstream (e.g. Publish-to-Atlas). AppBuilder now offers the same shape as create, for updates:

BigqueryCrawler(client).load(slug).include({...}).update()

load() seeds the builder from the workflow's current inputs; update() full-replaces while:

It's on the base AppBuilder, so every app builder inherits it (Snowflake, Postgres, …), not just BigQuery.

Note for the backend: ideally the server's UPDATE path should derive connection_qualified_name like CREATE does, so the SDK wouldn't need to re-inject it. Filing separately.

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 in load().

A reused guid must ride on the connection entity (attributes.defaultCredentialGuid, the UI's wire shape) with top-level credential_guid left "". 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 a gcp-wif credential to {"credentialSource":"direct"} and wiping its authType/extra.*. Every workflow sharing that guid then breaks: the BigQuery miner preflight resolves the credential as a service account and raises MissingCredentialsError on a WIF connection whose crawler succeeds (reproduced end-to-end — fresh crawler runs to Succeeded, then the miner fails preflight on the same credential; fixed once the guid rides on the connection).

Before (top-level — corrupts the shared credential):

"connection": { "attributes": { "qualifiedName": "default/bigquery/1700000000", "connectorName": "bigquery" } },
"credential_guid": "<existing-cred-guid>"

After (rides on the connection — stored credential untouched):

"connection": { "attributes": { "qualifiedName": "default/bigquery/1700000000", "connectorName": "bigquery", "defaultCredentialGuid": "<existing-cred-guid>" } },
"credential_guid": ""

The read-back half added here: load() reads the reused guid from attributes.defaultCredentialGuid (falling back to top-level for older workflows), so load().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

BigqueryCrawler(client)
    .workload_identity_federation(
        project_id="my-project",
        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="…",
    )
    .connection(name="prod-bigquery", admin_users=["jdoe"])
    .include({"my-project": ["analytics", "sales"]})
    .create()

RequestPOST /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

BigqueryCrawler(client)
    .workload_identity_federation(
        project_id="my-project",
        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="…",
    )
    .connection(name="prod-bigquery", admin_users=["jdoe"])
    .include({"my-project": ["analytics", "sales"]})
    .run()

RequestPOST /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)
)

RequestPUT /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

client.app.submit(slug)

RequestPOST /api/service/v1/app/{slug}/submit
(no request body)

Run status (poll until terminal)

SDK

client.app.get_run(run_id)

RequestGET /api/service/v1/app/runs/{run_id}
(no request body)

Cancel an in-flight run

SDK

client.app.cancel_run(run_id)

RequestPOST /api/service/v1/app/runs/{run_id}/cancel
(no request body)

Add a schedule

SDK

client.app.add_schedule(slug, "0 9 * * *", "UTC")

RequestPOST /api/service/v1/app/{slug}/schedule

{
  "cron": "0 9 * * *",
  "timezone": "UTC"
}

Remove a schedule

SDK

client.app.remove_schedule(slug, trigger_id)

RequestDELETE /api/service/v1/app/{slug}/schedule/{trigger_id}
(no request body)

Get a workflow

SDK

client.app.get(slug)

RequestGET /api/service/v1/app/{slug}
(no request body)

List / resolve slug by name

SDK

client.app.get_all(name="prod-bigquery")

RequestGET /api/service/v1/app?limit={n}&cursor={c}&name={name}
(no request body)

Delete a workflow

SDK

client.app.delete(slug)

RequestDELETE /api/service/v1/app/{slug}
(no request body)

Describe an app

SDK

client.app.describe("bigquery-crawler")

RequestGET /api/service/v1/apps/bigquery-crawler
(no request body)

Input contract

SDK

client.app.get_input_contract("bigquery-crawler", "crawler")

RequestGET /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-injects connection_qualified_name, references the credential on the connection (defaultCredentialGuid, top-level credential_guid ""; no rotation), normalizes control_config, drops runtime keys, preserves the rest; update() before load() raises.
    • generic across every builder — parametrized load()/update() (re-inject + credential reference on the connection, which also proves load() reads the reused guid back from defaultCredentialGuid + slug/entrypoint).
    • multiple field changes in one update (include/exclude/regex/toggles/custom_config) all apply while the rest is preserved.
    • update() 1003 fallback — retries at the default entrypoint when the named one has no registered contract.
  • Full app suite green; ruff clean. No version.txt/HISTORY.md bump — versions are cut via the [release] flow.

@Aryamanz29 Aryamanz29 changed the title feat(apps): type the BigQuery crawler WIF credential inputs feat(apps)!: type the BigQuery crawler WIF credential inputs (breaking) Aug 18, 2026
@Aryamanz29
Aryamanz29 force-pushed the aryaman/bigquery-wif-typed branch from a4c700a to 2e82483 Compare August 18, 2026 11:26
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
Aryamanz29 force-pushed the aryaman/bigquery-wif-typed branch from 0bfe236 to ddb273d Compare August 19, 2026 04:34
@Aryamanz29 Aryamanz29 changed the title feat(apps)!: type the BigQuery crawler WIF credential inputs (breaking) feat(apps)!: typed BigQuery WIF credentials + load()/update() for app-workflow updates Aug 19, 2026
Aryamanz29 and others added 6 commits August 19, 2026 10:23
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants