You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by yu...@apache.org on 2026/05/27 03:58:28 UTC
(polaris) branch main updated: docs: add production configuration pages for AWS S3 and Azure Blob storage (#1325) (#4451)
This is an automated email from the ASF dual-hosted git repository.
flyrain pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 6566296cd4 docs: add production configuration pages for AWS S3 and Azure Blob storage (#1325) (#4451)
6566296cd4 is described below
commit 6566296cd4d43236b3666ec161e17ba88df88f01
Author: ChangHyeon Im <uc...@gmail.com>
AuthorDate: Wed May 27 12:58:25 2026 +0900
docs: add production configuration pages for AWS S3 and Azure Blob storage (#1325) (#4451)
---
.../configuring-aws-s3-cloud-storage-specific.md | 325 +++++++++++++++++++++
...onfiguring-azure-blob-cloud-storage-specific.md | 223 ++++++++++++++
2 files changed, 548 insertions(+)
diff --git a/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-aws-s3-cloud-storage-specific.md b/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-aws-s3-cloud-storage-specific.md
new file mode 100644
index 0000000000..f99fcaa258
--- /dev/null
+++ b/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-aws-s3-cloud-storage-specific.md
@@ -0,0 +1,325 @@
+---
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+title: Configuring S3 Storage
+linkTitle: Configuring S3 Storage
+type: docs
+weight: 610
+---
+
+This page covers configuring AWS S3, and S3-compatible object stores (MinIO, Apache Ozone S3
+gateway, Ceph RGW, and similar), as the storage backend for a Polaris catalog. On AWS S3, all read
+and write operations are performed using credential vending: Polaris assumes a customer IAM role
+via STS and returns scoped, short-lived credentials to the client. The IAM role, its trust policy,
+and the bucket itself must be set up before the catalog is created.
+
+This page is limited to native Polaris authentication. External identity providers are also
+supported but are not yet covered here; the configuration patterns below remain otherwise the same.
+
+## IAM identities involved
+
+Three distinct IAM identities take part in the S3 credential-vending flow. Conflating them is the
+most common source of `AccessDenied` errors at catalog creation or table load.
+
+| # | Identity | Used by | Purpose |
+|---|----------|---------|---------|
+| 1 | Polaris service identity | Polaris server process | Sign every `sts:AssumeRole` request Polaris makes |
+| 2 | Catalog access role | Polaris (assumed) | Hold the actual S3 / KMS permissions on the catalog bucket |
+| 3 | Vended credentials | Iceberg client (Spark/Trino/PyIceberg) | Short-lived session keys returned by Polaris at table-load time |
+
+At runtime the client calls Polaris to load a table, Polaris signs an `sts:AssumeRole` request
+as identity 1 against identity 2, AWS STS returns scoped temporary credentials (identity 3), and
+the client uses those credentials to talk to S3 and KMS directly.
+
+Identity 1 is configured once at Polaris deployment time. Identity 2 is created per catalog and
+its ARN is registered when the catalog is created. Identity 3 is generated on every table load
+and is never persisted.
+
+## Polaris service identity
+
+The Polaris server itself needs an AWS identity to call STS on the catalog access role. This is
+configured outside Polaris — through the standard AWS SDK credentials chain — and is independent
+of any catalog.
+
+Pick whichever discovery mechanism matches the deployment:
+
+- **EKS / IRSA** — annotate the Polaris ServiceAccount with the IAM role ARN
+ (`eks.amazonaws.com/role-arn`). The pod receives a projected token and exchanges it for STS
+ credentials automatically.
+- **EC2** — attach an IAM instance profile to the EC2 instance. The SDK reads credentials from
+ IMDS.
+- **Static credentials** — set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally
+ `AWS_SESSION_TOKEN` and `AWS_REGION` in the Polaris container's environment. Suitable for local
+ development only.
+
+Any other AWS compute environment that participates in the standard AWS SDK credentials chain
+should also work, though the patterns above are the ones we have validated.
+
+Whichever mechanism is used, the resulting identity needs a single permission to talk to STS:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": "sts:AssumeRole",
+ "Resource": "arn:aws:iam::123456789012:role/polaris-warehouse-access"
+ }
+ ]
+}
+```
+
+`Resource` should list every catalog access role Polaris is expected to assume; use a wildcard
+(for example `arn:aws:iam::123456789012:role/polaris-*`) only when role names follow a strict
+naming convention that the AWS account owner controls.
+
+The ARN of this identity is the `Principal` that the catalog access role's trust policy must
+trust — see the next section. One easy mistake is to update the catalog role's permissions
+without also adding the Polaris service identity to its trust policy; the symptom is
+`STS:AssumeRole` returning `AccessDenied` even though the catalog role has correct S3
+permissions.
+
+For an end-to-end deployment example that exercises this identity on EC2, see
+[Deploying Polaris on AWS]({{% relref "../../getting-started/deploying-polaris/cloud-deploy/deploy-aws" %}}).
+
+## Catalog access role and trust policy
+
+Polaris assumes a customer-managed IAM role via STS when a client requests credentials. The role
+must:
+
+1. Grant the actions required for object access on the bucket and prefix that backs the catalog
+ (`s3:GetObject`, `s3:PutObject`, `s3:DeleteObject`, `s3:ListBucket` and, if encryption is in use,
+ the relevant `kms:*` actions).
+2. Trust the Polaris service principal — typically the IAM role that the Polaris server runs as.
+ Polaris fills the `sts:AssumeRole` request with an `externalId` when one is configured. The
+ trust policy must accept the same external ID.
+
+Using `externalId` is recommended for cross-account or hosted Polaris deployments to mitigate the
+confused-deputy problem. A minimal trust policy looks like:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": { "AWS": "arn:aws:iam::123456789012:role/polaris-server" },
+ "Action": "sts:AssumeRole",
+ "Condition": {
+ "StringEquals": { "sts:ExternalId": "polaris-prod" }
+ }
+ }
+ ]
+}
+```
+
+## Catalog storage configuration
+
+Provide the role ARN, region, and `externalId` when creating the catalog. The token in the
+`Authorization` header below is the Polaris admin bearer token obtained from
+`/api/catalog/v1/oauth/tokens` (see [Configuring Polaris for Production]({{% relref "." %}}) for
+how to bootstrap and issue admin tokens).
+
+```bash
+curl -X POST https://<polaris-host>/management/v1/catalogs \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "catalog": {
+ "type": "INTERNAL",
+ "name": "warehouse_s3",
+ "properties": { "default-base-location": "s3://warehouse-bucket/prod/" },
+ "storageConfigInfo": {
+ "storageType": "S3",
+ "roleArn": "arn:aws:iam::123456789012:role/polaris-warehouse-access",
+ "externalId": "polaris-prod",
+ "region": "us-east-1"
+ }
+ }
+ }'
+```
+
+The role ARN is validated against the pattern enforced by `AwsStorageConfigurationInfo`; an
+ill-formed ARN is rejected at catalog creation time.
+
+## Server-side encryption with KMS
+
+When the bucket uses SSE-KMS, supply both `currentKmsKey` (the key Polaris should use for writes)
+and `allowedKmsKeys` (every key the catalog is allowed to read from). The two fields are processed
+independently in `AwsCredentialsStorageIntegration`, so the write key must be included in
+`allowedKmsKeys` as well if you want it readable through vended credentials:
+
+```json
+"storageConfigInfo": {
+ "storageType": "S3",
+ "roleArn": "...",
+ "region": "us-east-1",
+ "currentKmsKey": "arn:aws:kms:us-east-1:123456789012:key/aaaa-bbbb",
+ "allowedKmsKeys": [
+ "arn:aws:kms:us-east-1:123456789012:key/aaaa-bbbb",
+ "arn:aws:kms:us-east-1:123456789012:key/cccc-dddd"
+ ]
+}
+```
+
+The IAM role's policy must include `kms:GenerateDataKey` and `kms:Decrypt` on `currentKmsKey` and
+`kms:Decrypt` on every key listed in `allowedKmsKeys`, and each key policy must grant the same to
+the role principal.
+
+If the deployment does not use KMS, set `kmsUnavailable` to `true` so Polaris will not request
+KMS-related session permissions:
+
+```json
+"kmsUnavailable": true
+```
+
+## S3-compatible endpoints
+
+Polaris can be pointed at S3-compatible object stores (MinIO, Ceph RGW, Apache Ozone S3 gateway).
+The available fields are:
+
+- `endpoint` — the S3 API endpoint Polaris and its clients should call.
+- `endpointInternal` — optional, used by the Polaris server when the in-cluster endpoint differs
+ from the one returned to clients.
+- `pathStyleAccess` — set to `true` for backends that do not support virtual-host-style addressing.
+- `stsEndpoint` — STS endpoint; defaults to `endpointInternal` then `endpoint` when not set.
+- `stsUnavailable` — set to `true` when the backend does not implement STS.
+
+The credential-vending guarantee at the top of this page assumes that the backend implements STS.
+For AWS S3 and S3-compatible backends that expose the STS API (such as MinIO), leave
+`stsUnavailable` unset (or `false`) and the vended-credentials flow described above works as is.
+
+```json
+"storageConfigInfo": {
+ "storageType": "S3",
+ "endpoint": "https://s3.internal.example.com",
+ "pathStyleAccess": true,
+ "region": "us-east-1"
+}
+```
+
+For S3-compatible backends without STS (Apache Ozone S3 gateway, or Ceph RGW without STS enabled),
+set `stsUnavailable: true`. Polaris will then skip subscoped credential vending entirely, and the
+client must omit `X-Iceberg-Access-Delegation: vended-credentials` and authenticate to the object
+store directly. The Polaris guides for [Apache Ozone][ozone-guide] and [Ceph][ceph-guide] show
+this pattern end-to-end.
+
+```json
+"storageConfigInfo": {
+ "storageType": "S3",
+ "endpoint": "https://s3.internal.example.com",
+ "pathStyleAccess": true,
+ "stsUnavailable": true,
+ "region": "us-east-1"
+}
+```
+
+[ozone-guide]: ../../../../guides/ozone/
+[ceph-guide]: ../../../../guides/ceph/
+
+## Client configuration
+
+Engines connect through the Iceberg REST API and let Polaris vend credentials at table-load time;
+they do not need static AWS credentials when STS is available.
+
+Spark example, matching the property names used by the existing MinIO / RustFS guides:
+
+```shell
+bin/spark-sql \
+ --packages org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.10.1,org.apache.iceberg:iceberg-aws-bundle:1.10.1 \
+ --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
+ --conf spark.sql.catalog.polaris=org.apache.iceberg.spark.SparkCatalog \
+ --conf spark.sql.catalog.polaris.type=rest \
+ --conf spark.sql.catalog.polaris.uri=https://<polaris-host>/api/catalog \
+ --conf spark.sql.catalog.polaris.oauth2-server-uri=https://<polaris-host>/api/catalog/v1/oauth/tokens \
+ --conf spark.sql.catalog.polaris.token-refresh-enabled=false \
+ --conf spark.sql.catalog.polaris.warehouse=warehouse_s3 \
+ --conf spark.sql.catalog.polaris.scope=PRINCIPAL_ROLE:ALL \
+ --conf spark.sql.catalog.polaris.credential=<client-id>:<client-secret> \
+ --conf spark.sql.catalog.polaris.header.X-Iceberg-Access-Delegation=vended-credentials
+```
+
+The `oauth2-server-uri` is recommended: without it the Iceberg REST client falls back to a
+hard-coded `/v1/oauth/tokens` path and logs a deprecation warning, since the automatic fallback
+is slated for removal in a future Iceberg release.
+
+For Trino, use the Iceberg connector with the REST catalog. The REST/OAuth2 properties talk to
+Polaris, and Polaris vends the endpoint, path-style flag, and region together with the scoped
+credentials (`s3.endpoint`, `s3.path-style-access`, `client.region` in the load-table response),
+so they do not need to be repeated on the client. The native S3 filesystem still has to be
+enabled on the Trino side:
+
+```properties
+connector.name=iceberg
+iceberg.catalog.type=rest
+iceberg.rest-catalog.uri=https://<polaris-host>/api/catalog
+iceberg.rest-catalog.warehouse=warehouse_s3
+iceberg.rest-catalog.security=OAUTH2
+iceberg.rest-catalog.oauth2.credential=<client-id>:<client-secret>
+iceberg.rest-catalog.oauth2.scope=PRINCIPAL_ROLE:ALL
+iceberg.rest-catalog.oauth2.server-uri=https://<polaris-host>/api/catalog/v1/oauth/tokens
+iceberg.rest-catalog.vended-credentials-enabled=true
+fs.native-s3.enabled=true
+```
+
+For PyIceberg, use the `rest` catalog type. The same Polaris-side properties (`uri`, `warehouse`,
+`credential`, `scope`, `oauth2-server-uri`) apply, and the vended-credential header must be
+forwarded as a REST header:
+
+```python
+from pyiceberg.catalog.rest import RestCatalog
+
+cat = RestCatalog(
+ name="polaris",
+ **{
+ "uri": "https://<polaris-host>/api/catalog",
+ "warehouse": "warehouse_s3",
+ "credential": "<client-id>:<client-secret>",
+ "scope": "PRINCIPAL_ROLE:ALL",
+ "oauth2-server-uri": "https://<polaris-host>/api/catalog/v1/oauth/tokens",
+ "header.X-Iceberg-Access-Delegation": "vended-credentials",
+ },
+)
+```
+
+Polaris returns the vended S3 properties (`s3.access-key-id`, `s3.secret-access-key`,
+`s3.session-token`) to the client at table-load time, so static credentials should not be
+configured on the PyIceberg side.
+
+## Verifying the setup
+
+A successful end-to-end test should be possible without giving the client any long-lived AWS
+credentials:
+
+```sql
+CREATE NAMESPACE warehouse_s3.demo;
+CREATE TABLE warehouse_s3.demo.t (id BIGINT, name STRING) USING iceberg;
+INSERT INTO warehouse_s3.demo.t VALUES (1, 'hello');
+SELECT * FROM warehouse_s3.demo.t;
+```
+
+If `INSERT` or `SELECT` fails with a 403, the most common causes are:
+
+- The IAM role's trust policy does not match the `roleArn` / `externalId` Polaris is presenting.
+- The role grants S3 permissions but is missing the KMS actions for `currentKmsKey`.
+- The bucket policy denies access from outside a specific VPC endpoint.
+
+Polaris logs the assumed-role STS request at debug level, which is the fastest way to confirm
+which identity is being presented.
diff --git a/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-azure-blob-cloud-storage-specific.md b/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-azure-blob-cloud-storage-specific.md
new file mode 100644
index 0000000000..17a235f7e5
--- /dev/null
+++ b/site/content/in-dev/unreleased/configuration/configuring-polaris-for-production/configuring-azure-blob-cloud-storage-specific.md
@@ -0,0 +1,223 @@
+---
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+title: Configuring Azure Blob Cloud Storage
+linkTitle: Configuring Azure Blob Cloud Storage
+type: docs
+weight: 620
+---
+
+This page covers configuring Azure Blob Storage and Azure Data Lake Storage Gen2 (ADLS Gen2) as
+the storage backend for a Polaris catalog. Polaris authenticates against Azure with the credentials
+of a service principal that has data-plane access to the target storage account, and then vends
+short-lived SAS tokens to clients on each table-load request.
+
+## Service principal and Polaris credentials
+
+Polaris uses the Azure SDK's `DefaultAzureCredential` chain, which by default reads the
+service-principal credentials from environment variables. Create a service principal with data
+access to the storage account and pass its credentials to the Polaris process:
+
+```bash
+# Replace <subscription>, <resource-group>, <storage-account> with your values.
+az ad sp create-for-rbac \
+ --name polaris-storage \
+ --role "Storage Blob Data Contributor" \
+ --scopes "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.Storage/storageAccounts/<storage-account>"
+```
+
+The command prints `appId`, `password`, and `tenant`. Set these on the Polaris server:
+
+```bash
+export AZURE_TENANT_ID=<tenant>
+export AZURE_CLIENT_ID=<appId>
+export AZURE_CLIENT_SECRET=<password>
+```
+
+In a Kubernetes deployment, do not embed `AZURE_CLIENT_SECRET` in the pod spec as plain text.
+Store the client secret in a Kubernetes `Secret` (or an external secret store referenced by an
+operator like the Azure Key Vault provider) and project it into the Polaris container with
+`envFrom`/`valueFrom: secretKeyRef`. The same applies to the bootstrap credentials in
+`POLARIS_BOOTSTRAP_CREDENTIALS`.
+
+The `Storage Blob Data Contributor` role at storage-account scope lets Polaris issue SAS tokens
+for any container under that account; scope the role narrower (single container) when you want to
+confine a single Polaris catalog to one container.
+
+## Storage account requirements
+
+The storage account that backs the catalog should be configured with:
+
+- **Hierarchical namespace (HNS)** is not strictly required by Polaris or Iceberg for table
+ operations themselves. Its main effect is on how narrowly Polaris can scope a vended SAS token:
+ with HNS enabled, Polaris can downscope a token to the directory (folder) that backs the
+ requested namespace or table; without HNS the token can only be scoped at the container level.
+ Production deployments that need per-namespace isolation should enable HNS.
+- The `hierarchical` field in `storageConfigInfo` must match the actual state of the storage
+ account. If they disagree, vended tokens are scoped against directory ACLs that do not exist
+ on the storage side and runtime access errors result.
+- A **container** that will hold the catalog's namespaces and tables (for example `warehouse`).
+ Polaris does not create the container itself.
+- **Firewall** rules that permit traffic from the Polaris control plane and from the engines that
+ will read the data. SAS tokens do not bypass storage-account firewalls.
+
+## Catalog storage configuration
+
+With the service principal in place on the server, create the catalog with the storage account's
+tenant ID and the `abfss://` location:
+
+```bash
+curl -X POST https://<polaris-host>/management/v1/catalogs \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "catalog": {
+ "type": "INTERNAL",
+ "name": "warehouse_azure",
+ "properties": {
+ "default-base-location": "abfss://warehouse@example.dfs.core.windows.net/prod/"
+ },
+ "storageConfigInfo": {
+ "storageType": "AZURE",
+ "tenantId": "00000000-0000-0000-0000-000000000000",
+ "hierarchical": true,
+ "allowedLocations": [
+ "abfss://warehouse@example.dfs.core.windows.net/"
+ ]
+ }
+ }
+ }'
+```
+
+`default-base-location` must use the `abfss://` scheme together with the ADLS Gen2 endpoint
+(`<account>.dfs.core.windows.net`). The `wasbs://` scheme is not supported.
+
+`AzureStorageConfigurationInfo` also accepts `multiTenantAppName` and `consentUrl`. Current
+Apache Polaris code does not use these fields when communicating with Azure APIs; they are
+informational and can be omitted in self-hosted deployments that authenticate with a service
+principal as described above.
+
+## SAS token scoping and HNS ACLs
+
+When HNS is enabled (`hierarchical: true`), Polaris narrows each vended SAS token to the directory
+that backs the requested namespace or table. The ADLS Gen2 ACL on that directory must include the
+service principal as well as any extra principals that should read the data outside of vended
+credentials.
+
+A common failure mode is a token that grants object-level permissions but is denied by a
+directory-level ACL. The 403 returned by ADLS includes the path that was denied; align the ACL on
+that exact prefix to recover.
+
+When HNS is disabled, set `hierarchical: false`. Polaris will then issue SAS tokens scoped at the
+container level rather than per-directory; this is acceptable for small deployments but does not
+restrict cross-namespace access.
+
+## Client configuration
+
+Engines call Polaris through the Iceberg REST API and receive SAS-token properties at table-load
+time; they do not need static Azure credentials on the client side.
+
+Spark example, matching the property names used by the existing MinIO / RustFS guides:
+
+```shell
+bin/spark-sql \
+ --packages org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.10.1,org.apache.iceberg:iceberg-azure-bundle:1.10.1 \
+ --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
+ --conf spark.sql.catalog.polaris=org.apache.iceberg.spark.SparkCatalog \
+ --conf spark.sql.catalog.polaris.type=rest \
+ --conf spark.sql.catalog.polaris.uri=https://<polaris-host>/api/catalog \
+ --conf spark.sql.catalog.polaris.oauth2-server-uri=https://<polaris-host>/api/catalog/v1/oauth/tokens \
+ --conf spark.sql.catalog.polaris.token-refresh-enabled=false \
+ --conf spark.sql.catalog.polaris.warehouse=warehouse_azure \
+ --conf spark.sql.catalog.polaris.scope=PRINCIPAL_ROLE:ALL \
+ --conf spark.sql.catalog.polaris.credential=<client-id>:<client-secret> \
+ --conf spark.sql.catalog.polaris.header.X-Iceberg-Access-Delegation=vended-credentials \
+ --conf spark.sql.catalog.polaris.io-impl=org.apache.iceberg.azure.adlsv2.ADLSFileIO
+```
+
+The `oauth2-server-uri` is recommended: without it the Iceberg REST client falls back to a
+hard-coded `/v1/oauth/tokens` path and logs a deprecation warning, since the automatic fallback
+is slated for removal in a future Iceberg release.
+
+The Spark application must include the Iceberg Azure bundle (or `iceberg-azure` and the matching
+Hadoop / Azure jars) on the classpath; Polaris does not ship these jars to the engine.
+
+For Trino, use the Iceberg connector with the REST catalog, in the same shape as the AWS S3
+example on the sibling page:
+
+```properties
+connector.name=iceberg
+iceberg.catalog.type=rest
+iceberg.rest-catalog.uri=https://<polaris-host>/api/catalog
+iceberg.rest-catalog.warehouse=warehouse_azure
+iceberg.rest-catalog.security=OAUTH2
+iceberg.rest-catalog.oauth2.credential=<client-id>:<client-secret>
+iceberg.rest-catalog.oauth2.scope=PRINCIPAL_ROLE:ALL
+iceberg.rest-catalog.oauth2.server-uri=https://<polaris-host>/api/catalog/v1/oauth/tokens
+```
+
+For PyIceberg, use the `rest` catalog type and forward the vended-credential header as a REST
+header:
+
+```python
+from pyiceberg.catalog.rest import RestCatalog
+
+cat = RestCatalog(
+ name="polaris",
+ **{
+ "uri": "https://<polaris-host>/api/catalog",
+ "warehouse": "warehouse_azure",
+ "credential": "<client-id>:<client-secret>",
+ "scope": "PRINCIPAL_ROLE:ALL",
+ "oauth2-server-uri": "https://<polaris-host>/api/catalog/v1/oauth/tokens",
+ "header.X-Iceberg-Access-Delegation": "vended-credentials",
+ },
+)
+```
+
+Polaris vends per-account SAS tokens to the FileIO at table-load time. The credentials are
+returned under keys of the form `adls.sas-token.<storage-account>` (with optional
+`dfs.core.windows.net` / `blob.core.windows.net` suffixes when scoping requires it), as defined in
+`StorageAccessProperty`. The PyIceberg client picks these up directly; no static account key or
+SAS token needs to be configured.
+
+## Verifying the setup
+
+```sql
+CREATE NAMESPACE warehouse_azure.demo;
+CREATE TABLE warehouse_azure.demo.t (id BIGINT, name STRING) USING iceberg;
+INSERT INTO warehouse_azure.demo.t VALUES (1, 'hello');
+SELECT * FROM warehouse_azure.demo.t;
+```
+
+If any operation fails:
+
+- Errors from Azure AD (`AADSTS*`) usually mean the service principal credentials in
+ `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` / `AZURE_TENANT_ID` are wrong, the secret has expired,
+ or the principal has no role assignment on the storage account.
+- `Failed to get subscoped credentials` with `Status code 403` and an
+ `AuthorizationPermissionMismatch` Azure error body means the service principal does not have a
+ data-plane role (`Storage Blob Data Contributor` or equivalent) at a scope that covers the path
+ being accessed. Granting the role on the storage account — or the specific container — and
+ waiting for RBAC propagation resolves it.
+- 403 from `dfs.core.windows.net` paths with no Azure error code typically points at an HNS ACL
+ mismatch on the base location when HNS is enabled and a narrow ACL is in place on the
+ directory.
+- 404 on container-level operations indicates that the container does not yet exist; Polaris does
+ not create containers, only directories and blobs underneath them.