laskoviymishka commented on code in PR #2163:
URL: https://github.com/apache/iceberg-go/pull/2163#discussion_r4234099472
##########
catalog/rest/rest.go:
##########
@@ -1566,7 +1566,7 @@ func (r *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
maps.Copy(saved, ret.Config)
scanPlanningConfig := maps.Clone(config)
credsVended := len(ret.StorageCredentials) > 0
- maps.Copy(config, resolveStorageCredentials(ret.StorageCredentials,
ret.MetadataLoc))
+ mergeVendedCredentials(config,
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc))
Review Comment:
Worth being explicit that this is a behavior change for S3/GCS users, not
just plumbing for the new token. The clear-then-copy used to run only inside
`propertiesForLocation`, where a credential's Config is a complete bundle; here
`config` is the table-wide map including `ret.Config`, so a server that returns
a static access/secret pair in config and vends only `s3.session-token` now has
that pair wiped and is left with just the token.
I'd either split the helper plus the five site conversions into their own PR
and describe it as a semantics change, or at minimum call it out in the
description and decide explicitly whether a partial vended credential should
replace or layer onto the base config.
##########
catalog/rest/refresh_table_credentials_test.go:
##########
@@ -466,3 +466,63 @@ func TestRefreshTableCredentialsNotFound(t *testing.T) {
assert.Nil(t, refreshed)
assert.ErrorIs(t, err, catalog.ErrNoSuchTable)
}
+
+// TestRefreshTableCredentialsDropsStaleCredentials checks that refreshed
+// credentials replace, rather than sit beside, credentials of the same kind
left
+// in the table's saved config. A stale higher-precedence credential would
+// otherwise shadow the freshly vended one.
+func TestRefreshTableCredentialsDropsStaleCredentials(t *testing.T) {
Review Comment:
I'd extend coverage past RefreshTableCredentials — the three new merge sites
(CreateTable/RegisterTable/loadTableWithMode) go through the same helper but
have no test, and those are exactly where `config` is the table-wide map rather
than a per-location copy, so the cross-account and partial-credential cases
below actually bite there.
A table test through the real Create/Register/Load handlers with (a) a
second-account SAS that must survive a vended token and (b) a partial vended S3
credential layered over a full key pair in the server config block would pin
both blockers.
##########
catalog/rest/vended_creds.go:
##########
@@ -474,6 +486,26 @@ func clearOverriddenCredentialProperties(props,
credentialConfig iceberg.Propert
delete(props, keyAdlsSasExpiresAtMs+"."+suffix)
}
}
+
+ if credentialConfig[iceio.ADLSToken] != "" {
+ // adls.token is evaluated after shared-key, SAS-token, and
connection-string
+ // auth, so any of those left over from the table or catalog
configuration
+ // would shadow a freshly vended token and the read would 403.
props is a
+ // per-location copy, so clearing them cannot affect another
location's IO.
+ //
+ // The value must be nonempty: the Azure bucket factory skips
an empty
+ // adls.token, so clearing on mere key presence would discard
working
+ // credentials and fall through to a weaker auth method.
+ delete(props, iceio.ADLSSharedKeyAccountName)
+ delete(props, iceio.ADLSSharedKeyAccountKey)
+ for key := range props {
+ if strings.HasPrefix(key, iceio.ADLSSasTokenPrefix) ||
+ strings.HasPrefix(key,
keyAdlsSasExpiresAtMs+".") ||
Review Comment:
This only clears the per-host `adls.sas-token-expires-at-ms.<host>` form,
but `parseCredentialExpiry` also reads the bare `keyAdlsSasExpiresAtMs` — so a
bare expiry key would survive the SAS wipe and dangle. Worth a `delete(props,
keyAdlsSasExpiresAtMs)` alongside the loop.
##########
catalog/rest/vended_creds_test.go:
##########
@@ -692,6 +692,96 @@ func TestPrefixScopedIOReplacesS3CredentialAtomically(t
*testing.T) {
assert.NotContains(t, props, keyS3TokenExpiresAtMs)
}
+func TestPrefixScopedIOClearsStaleADLSAuthForVendedToken(t *testing.T) {
+ t.Parallel()
+
+ const host = "acct.dfs.core.windows.net"
+
+ p := newPrefixScopedIO(context.Background(), iceberg.Properties{
+ iceio.ADLSEndpoint:
"dfs.core.windows.net",
+ iceio.ADLSSharedKeyAccountName: "acct",
+ iceio.ADLSSharedKeyAccountKey: "stale-key",
+ iceio.ADLSSasTokenPrefix + host: "stale-sas",
+ keyAdlsSasExpiresAtMs + "." + host: "1000",
+ iceio.ADLSConnectionStringPrefix + "acct":
"stale-connection-string",
+ }, []StorageCredential{{
+ Prefix: "abfss://container@" + host + "/",
+ Config: iceberg.Properties{iceio.ADLSToken: "vended-token"},
+ }})
+
+ props := p.propertiesForLocation("abfss://container@" + host +
"/table/metadata.json")
+
+ // The vended token and unrelated tuning properties survive.
+ assert.Equal(t, "vended-token", props[iceio.ADLSToken])
+ assert.Equal(t, "dfs.core.windows.net", props[iceio.ADLSEndpoint])
+
+ // Everything that outranks adls.token in the Azure auth chain is gone,
so the
+ // vended token is what actually authenticates the read.
+ assert.NotContains(t, props, iceio.ADLSSharedKeyAccountName)
+ assert.NotContains(t, props, iceio.ADLSSharedKeyAccountKey)
+ assert.NotContains(t, props, iceio.ADLSSasTokenPrefix+host)
+ assert.NotContains(t, props, keyAdlsSasExpiresAtMs+"."+host)
+ assert.NotContains(t, props, iceio.ADLSConnectionStringPrefix+"acct")
+}
+
+func TestPrefixScopedIOKeepsCredentialsWhenVendedADLSTokenIsEmpty(t
*testing.T) {
+ t.Parallel()
+
+ const host = "acct.dfs.core.windows.net"
+
+ p := newPrefixScopedIO(context.Background(), iceberg.Properties{
+ iceio.ADLSSharedKeyAccountName: "acct",
+ iceio.ADLSSharedKeyAccountKey: "working-key",
+ iceio.ADLSSasTokenPrefix + host: "working-sas",
+ }, []StorageCredential{{
+ Prefix: "abfss://container@" + host + "/",
+ Config: iceberg.Properties{iceio.ADLSToken: ""},
+ }})
+
+ props := p.propertiesForLocation("abfss://container@" + host +
"/table/metadata.json")
+
+ // The Azure bucket factory skips an empty adls.token, so clearing on
key
+ // presence alone would discard these working credentials and fall
through to
+ // a weaker auth method.
+ assert.Equal(t, "acct", props[iceio.ADLSSharedKeyAccountName])
+ assert.Equal(t, "working-key", props[iceio.ADLSSharedKeyAccountKey])
+ assert.Equal(t, "working-sas", props[iceio.ADLSSasTokenPrefix+host])
+}
+
+func TestVendedCredsRefreshClearsStaleADLSAuth(t *testing.T) {
+ const host = "acct.dfs.core.windows.net"
+
+ var captured iceberg.Properties
+ iceio.Register("vended-adls-refresh-test", func(_ context.Context, _
*url.URL, props map[string]string) (iceio.IO, error) {
Review Comment:
This one registers into the global scheme registry with no cleanup and
captures props in a closure, while the sibling refresh tests go through the
`ioPropsRecorder` helper that unregisters for you. I'd reuse `rec.register(t,
scheme)` here too so a `-count=N` run or a future parallel sibling doesn't trip
a re-register or a race.
##########
website/src/configuration.md:
##########
@@ -227,15 +227,20 @@ Tuning properties:
### Azure Data Lake Storage / Blob
-Authentication is selected in the following order
(`io/gocloud/azure/azure.go`); the first applicable option wins, and if none of
1-4 apply the default credential chain (5) is used:
+Authentication is selected in the following order
(`io/gocloud/azure/azure.go`); the first applicable option wins, and if none of
1-5 apply the default credential chain (6) is used:
1. Shared key: a nonempty `adls.auth.shared-key.account.name` selects this
path and requires a nonempty `adls.auth.shared-key.account.key`.
2. Per-host SAS token: `adls.sas-token.<hostname>`, where `<hostname>` exactly
matches the storage account hostname (for example,
`myaccount.dfs.core.windows.net`).
3. Per-account connection string: `adls.connection-string.<account-name>` (for
example, `adls.connection-string.myaccount`).
-4. Managed identity: `adls.auth.managed-identity.enabled` set to exactly
`"true"` selects this path and uses `ManagedIdentityCredential` directly. Set
`adls.client-id` to select a user-assigned managed identity; otherwise, the
system-assigned managed identity is used.
-5. Default credential chain: `DefaultAzureCredential`, which includes managed
identity among its credential sources.
+4. Static access token: a nonempty `adls.token`, supplied without the `Bearer
` prefix.
+5. Managed identity: `adls.auth.managed-identity.enabled` set to exactly
`"true"` selects this path and uses `ManagedIdentityCredential` directly. Set
`adls.client-id` to select a user-assigned managed identity; otherwise, the
system-assigned managed identity is used.
+6. Default credential chain: `DefaultAzureCredential`, which includes managed
identity among its credential sources.
-> **Note on cross-client parity.** The two properties in step 4 diverge from
the
+`adls.token` accepts an OAuth2 access token for Azure Storage, including a
token supplied by a catalog. An empty value is ignored. A rejected or expired
token does not fall back to managed identity or the default credential chain.
+
+The token is static and is not refreshed. As in
[Java](https://github.com/apache/iceberg/blob/main/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java)
and
[PyIceberg](https://github.com/apache/iceberg-python/blob/main/pyiceberg/io/fsspec.py),
the credential reports a one-hour cache window to the Azure SDK each time the
SDK requests the token. This does not extend the token's actual validity: Azure
enforces its expiration. Callers must obtain a new token and recreate the
FileIO when it expires; changing the original properties map does not update an
existing FileIO.
Review Comment:
Could we note this caveat for the catalog-vended path specifically? The "not
refreshed" line is right for a FileIO built from static props, but a REST
catalog can vend `adls.token` via storage-credentials, and the refresher has no
expiry key for it — so a vended token only gets the generic TTL fallback (or
never refreshes without `fetchCreds`), and a token whose real lifetime is
shorter than the fallback will 401 silently.
It matches Java (the spec defines no ADLS token expiry key), so documenting
the limitation may be enough, but honoring `expiration-time` for the token
would be the stronger fix. wdyt?
##########
catalog/rest/vended_creds.go:
##########
@@ -474,6 +486,26 @@ func clearOverriddenCredentialProperties(props,
credentialConfig iceberg.Propert
delete(props, keyAdlsSasExpiresAtMs+"."+suffix)
}
}
+
+ if credentialConfig[iceio.ADLSToken] != "" {
Review Comment:
This clears credentials for every account, not just the one the vended token
covers — the loop drops all `adls.sas-token.*` and `adls.connection-string.*`
plus the shared key regardless of host. The comment justifies that as safe
because props is a per-location copy, which only holds inside
`propertiesForLocation`; at the three table-wide sites and
RefreshTableCredentials, `config` is the whole table/catalog map.
Since `resolveStorageCredentials` matches one credential by longest
metadata-location prefix and data files can sit under a different account, a
vended token for account A silently clears a working SAS or connection string
for account B, and that read falls through to the default chain or 403s.
I'd scope the deletes to the matched credential's account/host, or keep this
wipe confined to the per-location path, and correct the comment either way.
##########
catalog/rest/vended_creds.go:
##########
@@ -432,14 +432,26 @@ func (p *prefixScopedIO) propertiesForLocation(name
string) iceberg.Properties {
props := make(iceberg.Properties, len(p.baseProps))
maps.Copy(props, p.baseProps)
if credentialIndex >= 0 {
- credentialConfig := p.credentials[credentialIndex].Config
- clearOverriddenCredentialProperties(props, credentialConfig)
- maps.Copy(props, credentialConfig)
+ mergeVendedCredentials(props,
p.credentials[credentialIndex].Config)
}
return props
}
+// mergeVendedCredentials applies a matched storage credential over config,
+// first dropping any inherited property that the credential is meant to
+// replace. Every site that merges vended credentials must go through here:
+// merging with maps.Copy alone leaves stale higher-precedence credentials in
+// place, which then shadow the freshly vended ones.
+func mergeVendedCredentials(config, credentialConfig iceberg.Properties) {
Review Comment:
Routing every merge through one chokepoint is the right call. The thing I'd
shore up is that the shadow-key list `clearOverriddenCredentialProperties`
deletes is really a mirror of the Azure factory's precedence order, but it
lives a file away from that factory with nothing tying them together — if the
factory order ever changes, this breaks silently with no compile error.
I'd either colocate that list with the auth code or add a test that pins the
two, and leave a one-line note that S3/GCS clear on key presence while ADLS
clears on a nonempty value, since that asymmetry isn't obvious to the next
reader.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]