# Creating an Airflow service account for running pipelines (/pipelines/service-account)

Give a pipeline its own identity, grant it access to the lakehouse, and hand it to Airflow as a connection.

A pipeline that touches the lakehouse authenticates as **its own identity**, not as you, and not as a
shared platform credential. That identity is a Zitadel service account, granted explicit permissions
on a warehouse and handed to Airflow as a connection. `databaas-utils` reads that connection and does
the rest, so no DAG ever holds a credential.

This page is the whole flow, once, for one pipeline. Every step happens in a browser.

## Before you start

- Admin in Zitadel for the environment: `https://auth.<your-databaas-domain>`
- Admin in the catalog UI: `https://catalog.<your-databaas-domain>`
- `can_admin` in Databaas, so you can open **Admin → Connections** in Airflow. See the
  [permissions reference](/access/permissions-reference).
- Airflow running an image with `databaas-utils` **0.2.1 or newer**. It is baked into the image; you
  do not pip install it and you do not add it to a `@task.virtualenv` requirement list.

`<your-databaas-domain>` is the domain you open the portal on. Zitadel and the catalog sit on the
`auth.` and `catalog.` subdomains of it.

## 1. Create the service account in Zitadel

1. Open `https://auth.<your-databaas-domain>` and sign in as an admin.
2. **Users → Service Users → New**. Name it after the pipeline it will run, for example
   `airflow-open-meteo`. Access Token Type **JWT**. Save.
3. Open the new user → **Keys** → **New** → type **JSON** → pick an expiry → **Add**.
4. The browser downloads a file **once**. There is no way to retrieve it again. If you lose it,
   delete the key and make a new one. You paste its contents into Airflow in step 3.

It looks like this:

```json
{
  "type": "serviceaccount",
  "keyId": "391305913807408172",
  "key": "-----BEGIN RSA PRIVATE KEY-----\n…\n-----END RSA PRIVATE KEY-----\n",
  "userId": "391305903103544364"
}
```

<Callout type="error">
  **Do not use a Personal Access Token.** Zitadel offers one on the same screen and it looks like the
  simpler option. It is not interchangeable: a PAT is an opaque credential for calling Zitadel's _own_
  APIs, not a JWT that a resource server's OIDC validator can parse. Lakekeeper rejects it with
  `AuthenticationFailed`. The JSON key is the supported credential: `databaas-utils` signs a
  short-lived assertion with the private key and exchanges it for a real access token (RFC 7523
  Private Key JWT). The resulting token is indistinguishable from what a client-secret flow
  produces; only how the client proves itself differs.
</Callout>

### Copy the project ID

While you are in Zitadel, open **Projects → Databaas** and copy the project's ID. Step 3 needs it
twice. Every token the pipeline gets must carry this project as its audience, or Lakekeeper and Trino
reject it.

## 2. Grant it read/write in the catalog UI

The account can authenticate but cannot see a single table until you grant it something.

1. Open `https://catalog.<your-databaas-domain>` and sign in.
2. Go to the **warehouse** (`databaas-lakehouse`), or the specific **namespace** the pipeline owns.
3. **Permissions** → add the service account by the name you gave it in step 1.
4. Grant what the pipeline actually needs, typically `select` **and** `modify`/`create` on its own
   namespace.

Prefer granting on the pipeline's own namespace over the whole warehouse. A pipeline that can only
write its own namespace cannot corrupt another team's tables through a bad `overwrite`.

<Callout>
  If the pipeline creates its own namespaces, which `scoped()` makes it do on every branch deploy, it
  needs the create grant one level up, on the warehouse, not only inside a namespace that does not
  exist yet.
</Callout>

## 3. Add the Airflow connection

This is what the DAG actually reads.

1. In **Pipelines**, open **Admin → Connections** and add a new connection.
2. Set **Connection Type** to **DataBaas Lakehouse** first. The form then shows the lakehouse fields
   below, with their own labels.
3. Fill in every field as in the table. Leave the ones marked _empty_ empty.
4. Save.

| Field | Value | Why |
| ----- | ----- | --- |
| Connection Id | `databaas_lakehouse` | The default `databaas-utils` looks for. `lakekeeper` is accepted as an alias for the older pipelines. |
| Connection Type | **DataBaas Lakehouse** | Registered by `databaas-utils` as a real connection type, so the UI gives a labelled form instead of a JSON blob. |
| Description | _optional_ | Free text, for example which pipeline uses the account. |
| Lakekeeper URL | `http://lakekeeper:8181` | The Lakekeeper **root**. **Not** the `/catalog` sub-path: a URL ending in `/catalog` is rejected outright, because the package appends it and you would get `…/v1/aws/s3/sign/v1/aws/s3/sign`. |
| Warehouse | `databaas-lakehouse` | The warehouse you granted on in step 2. |
| Client ID | _empty_ | There is no client secret in this flow; the JSON key replaces it. |
| Client secret | _empty_ | Same as Client ID. |
| Service account JSON key | the whole downloaded file | Open the file from step 1 in a text editor, select everything from the first `{` to the last `}`, and paste it unchanged. |
| JWT audience | _empty_ | Defaults to the Token URL without `/oauth/v2/token`, which is correct here. |
| Token URL | `https://auth.<your-databaas-domain>/oauth/v2/token` | Zitadel's token endpoint. It must be the full URL, including `https://` and `/oauth/v2/token`. |
| Zitadel project ID | the project ID from step 1 | The project whose audience Lakekeeper requires on every token. |
| OAuth scope | `openid urn:zitadel:iam:org:project:id:<project ID>:aud` | Puts the project ID in the token's audience. See the warning below. |
| Default namespace | _empty_ | Only needed if your DAGs call the helpers without a namespace and you want one other than the package default. |
| S3 region | _empty_ | Taken from the catalog's table config. |
| S3 endpoint | _empty_ | Taken from the catalog's table config. |
| TLS verification | _empty_ | Empty means certificates are verified. |

<Callout type="warn" title="Set OAuth scope, do not rely on the project ID alone">
  Without an explicit scope, `databaas-utils` derives one from the project ID that leaves out the
  trailing `:aud`. Lakekeeper accepts that token, so catalog reads and writes work, but Trino rejects
  it with `401 Invalid Audience`. Any DAG that queries through Trino, such as a dbt run, then fails.
  Setting **OAuth scope** to the value above fixes both. When **OAuth scope** is set it takes
  precedence over **Zitadel project ID**.
</Callout>

<Callout type="warn" title="Check the Token URL character by character">
  Airflow does not check the fields when you save. A typo in **Token URL**, such as a missing `/`
  before `oauth`, only shows up when the first task asks for a token, as a low-level connection
  error. Verify the connection (below) before you run a real DAG.
</Callout>

Paste the key from the file, never retype it. A mangled newline inside the private key fails as an
unrelated-looking signature error.

## 4. Use it in a DAG

Nothing to install and nothing to configure. Ask for what you need and the connection is resolved
behind it.

```python
from airflow.sdk import task


@task
def write_forecast(rows: list[dict]):
    import pyarrow as pa
    from databaas_utils.airflow import get_catalog, scoped

    catalog = get_catalog()
    table = catalog.load_table(f"{scoped('open_meteo')}.forecast")
    table.append(pa.Table.from_pylist(rows, schema=table.schema().as_arrow()))
```

The entry points:

| You want | Call |
| -------- | ---- |
| The Iceberg catalog | `get_catalog()` |
| Blobs on remote-signed S3, no credentials | `get_store()` |
| A Zarr group | `get_zarr_group("<table>")` |
| The index of what actually landed | `get_index()` |
| A name that a branch deploy cannot collide on | `scoped("<name>")` |
| All of the above off one connection | `LakehouseHook()` |

Each takes `conn_id=` if you registered the connection under a different name, and `namespace=` to
override the connection's default.

<Callout type="warn">
  Keep these imports **inside** the task body, not at the top of the DAG file. The dag-processor
  parses every DAG file in every bundle in one process; a module-level
  `from databaas_utils.airflow import get_catalog` drags pyiceberg and obstore into that process for a
  file that may never run. Inside a `@task.virtualenv` it is not merely preferable but required: the
  body closes over nothing. See [connections and variables](/pipelines/connections).
</Callout>

Failure is loud by design. An unresolvable connection raises `ConnectionNotConfigured` rather than
quietly falling back to a local directory. That was the old behaviour, where a typo'd conn id
redirected a production write to an ephemeral pod disk and the task still went green. If you
genuinely want a local run, pass `local_root=` and opt in.

## Verifying

If your Airflow shows a **Test** button on the connection form, use it. It is only there when
connection testing is enabled in Airflow's configuration, which Airflow 3 leaves off by default.

Otherwise, add this DAG to your pipeline repository and trigger it once from the Airflow UI:

```python title="dags/check_lakehouse_connection.py"
from airflow.sdk import dag, task


@dag(schedule=None, catchup=False)
def check_lakehouse_connection():
    @task
    def check():
        from databaas_utils.airflow import LakehouseHook

        ok, message = LakehouseHook().test_connection()
        print(message)
        if not ok:
            raise RuntimeError(message)

    check()


check_lakehouse_connection()
```

Both run the same check. It fetches a token and reads the warehouse, and reports the first link in
the chain that broke: the connection fields, the token endpoint, or the warehouse. Success reads
`Connected to … — warehouse 'databaas-lakehouse' resolved to …`.

The check talks to Lakekeeper only, so it does not catch a missing `:aud` in **OAuth scope**. If your
pipeline queries through Trino, confirm the scope against the table in step 3.

## Troubleshooting

| Symptom | Cause |
| ------- | ----- |
| `AuthenticationFailed` from Lakekeeper | A PAT was used instead of the JSON key. See step 1. |
| A connection or URL error while fetching a token | **Token URL** has a typo or is not the full `https://…/oauth/v2/token` URL. See step 3. |
| `service_account_key was supplied without token_url` | **Token URL** is empty. |
| `NoSuchWarehouse` | The account has no grant on the warehouse, or **Warehouse** has a typo. See step 2. |
| `403` / not authorized on a namespace or table | The account has no grant. See step 2. |
| `403` only when creating a namespace | The grant is on the namespace, not the warehouse. See step 2. |
| `401 Invalid Audience` from Trino | **OAuth scope** is empty or lacks the trailing `:aud`. See step 3. |
| `ConnectionNotConfigured` | No connection under that id. Check the id the DAG asks for as well as the one you created. |
| `ConfigurationError: … has no host` / `no warehouse` | **Lakekeeper URL** or **Warehouse** left empty. |
| Signature or token errors after editing the key | The private key's newlines were mangled. Paste the file's contents again. |
| `KeyError: 'LAKEKEEPER_URI'` | The DAG still reads the removed environment variables; it needs migrating to `databaas_utils`. |

## Notes

- **One account per pipeline, not one per person.** A pipeline authenticating as a human breaks when
  that human leaves, and its access is whatever that person happened to have rather than what the
  pipeline needs.
- **Keys expire.** You set the expiry yourself in step 1, so put it in a calendar. Rotation is: create
  a new key, paste it into the connection's **Service account JSON key** field, then delete the old
  key in Zitadel. Step 2 does not repeat, because the account identity does not change.
- **No S3 credentials appear anywhere in this flow.** Object storage is reached by asking Lakekeeper
  to sign each request, so there is no access key to leak, scope or rotate. That is the point of the
  whole arrangement.
- **Grant the narrowest thing that works.** The warehouse is the easy answer and usually the wrong
  one.