mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
`forceAnonymousS3DiskConfig` (the pre-resolution fallback used when `load_anonymously` is set, instead of the post-resolution per-prefix check) now rewrites every resolved S3 backend prefix -- the disk root and each `locations.<name>` child -- not only the root. An `include` that resolves a `locations.<name>` S3 child with server-managed auth no longer keeps its server identity (built with `for_disk_s3 = true`) when the disk is loaded anonymous on restart. Also add a regression that a persistent `ENGINE = S3(...)` table created with the opt-in refuses the first restricted read: the create-time settings update establishes the client's restriction mode, so the restricted read rebuilds an anonymous client and is refused rather than reusing the credentialed create-time client. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
593 lines
29 KiB
Python
593 lines
29 KiB
Python
import pytest
|
|
|
|
from helpers.cluster import ClickHouseCluster
|
|
from helpers.config_cluster import minio_access_key, minio_secret_key
|
|
|
|
# The server is given S3 credentials by server-managed mechanisms that user SQL must not be able to reuse
|
|
# when `s3_allow_server_credentials_in_user_queries` is disabled (the default). Here we exercise the AWS
|
|
# shared-credentials file (the ProfileConfigFileAWSCredentialsProvider): it points at the working minio
|
|
# credentials, and AWS_EC2_METADATA_DISABLED plus the absence of AWS_ACCESS_KEY_ID make it the only
|
|
# credential source, so the test isolates that provider specifically.
|
|
cluster = ClickHouseCluster(__file__)
|
|
node = cluster.add_instance(
|
|
"node",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_SHARED_CREDENTIALS_FILE": "/tmp/aws_credentials",
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance whose server <s3> config carries a global role_arn (and no environment
|
|
# credentials), to verify that user-facing S3 entry points do not inherit it. Separate because a global
|
|
# role_arn would interfere with the profile-file tests above once the override is enabled.
|
|
node_with_server_role = cluster.add_instance(
|
|
"node_with_server_role",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml", "configs/s3_server_role.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance whose server <s3> config carries static keys (and no environment credentials), to
|
|
# verify that a backup named collection overrides -- rather than inherits -- the server's static keys.
|
|
node_with_server_keys = cluster.add_instance(
|
|
"node_with_server_keys",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml", "configs/s3_server_keys.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance whose server <s3> config carries a per-endpoint session_token, to verify that an
|
|
# explicit-key user query does not inherit (and send) the server's temporary token.
|
|
node_with_server_session_token = cluster.add_instance(
|
|
"node_with_server_session_token",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml", "configs/s3_session_token.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance whose server <s3> config carries a per-endpoint gcp_oauth plus a complete Google ADC
|
|
# triple, to verify that an explicit-key user query/backup does not inherit (and use) the server gcp_oauth.
|
|
node_with_server_gcp_oauth = cluster.add_instance(
|
|
"node_with_server_gcp_oauth",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml", "configs/s3_gcp_oauth.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance whose server <s3> config carries an SSE-C customer key for an endpoint, to verify that a
|
|
# named collection with its own keys does not inherit (and send) the server's encryption key material.
|
|
node_with_server_sse = cluster.add_instance(
|
|
"node_with_server_sse",
|
|
with_minio=True,
|
|
main_configs=["configs/named_collections.xml", "configs/s3_sse_c.xml"],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
# A separate instance that allows `include` substitutions in dynamic disk definitions, to verify the
|
|
# post-include re-check inspects `locations.*` children (not only the disk root).
|
|
node_with_includes = cluster.add_instance(
|
|
"node_with_includes",
|
|
with_minio=True,
|
|
main_configs=[
|
|
"configs/named_collections.xml",
|
|
"configs/dynamic_disk_include.xml",
|
|
"configs/dynamic_disk_include_source.xml",
|
|
],
|
|
user_configs=["configs/users.xml"],
|
|
env_variables={
|
|
"AWS_EC2_METADATA_DISABLED": "true",
|
|
},
|
|
)
|
|
|
|
ALLOW = "SETTINGS s3_allow_server_credentials_in_user_queries = 1"
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def start_cluster():
|
|
try:
|
|
cluster.start()
|
|
# Stand in for the server's own AWS profile credentials.
|
|
node.exec_in_container(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"printf '[default]\\naws_access_key_id = %s\\naws_secret_access_key = %s\\n' > /tmp/aws_credentials"
|
|
% (minio_access_key, minio_secret_key),
|
|
]
|
|
)
|
|
yield
|
|
finally:
|
|
cluster.shutdown()
|
|
|
|
|
|
def test_profile_file_not_used_for_user_queries():
|
|
# No explicit keys, no env/IMDS credentials: the only possible source is the AWS shared-credentials
|
|
# file (ProfileConfigFileAWSCredentialsProvider). It must not be used by default.
|
|
url = "http://minio1:9001/root/profilecreds/data.tsv"
|
|
insert = (
|
|
f"INSERT INTO FUNCTION s3('{url}', format = 'TSV', structure = 'x UInt8') "
|
|
f"SELECT 1 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
error = node.query_and_get_error(insert)
|
|
assert "ACCESS_DENIED" in error, error
|
|
|
|
# With the setting enabled, the profile file is used and the request succeeds (proving the profile
|
|
# file really was the only available credential source).
|
|
node.query(insert + ", s3_allow_server_credentials_in_user_queries = 1")
|
|
assert (
|
|
node.query(
|
|
f"SELECT * FROM s3('{url}', format = 'TSV', structure = 'x UInt8') {ALLOW}"
|
|
).strip()
|
|
== "1"
|
|
)
|
|
|
|
|
|
def test_url_only_named_collection_stays_anonymous():
|
|
# A URL-only named collection means anonymous access. Even when a trusted session re-enables
|
|
# server-managed credentials, the collection must not silently pick up the server's AWS
|
|
# shared-credentials file; `use_environment_credentials = 1` is the explicit opt-in.
|
|
url = "http://minio1:9001/root/profilecreds_nc/data.tsv"
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 7 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
select_url_only = (
|
|
"SELECT * FROM s3(nc_url_only, format = 'TSV', structure = 'x UInt8')"
|
|
)
|
|
|
|
# Anonymous by default, and still anonymous with the override enabled: minio rejects the unsigned
|
|
# read of the private bucket instead of accepting the server profile credentials.
|
|
error = node.query_and_get_error(select_url_only)
|
|
assert "403" in error or "Access Denied" in error or "AccessDenied" in error, error
|
|
error = node.query_and_get_error(f"{select_url_only} {ALLOW}")
|
|
assert "403" in error or "Access Denied" in error or "AccessDenied" in error, error
|
|
|
|
# The explicit opt-in keeps working: with the override enabled and `use_environment_credentials = 1`
|
|
# the profile file is consulted and the read succeeds.
|
|
select_with_env = (
|
|
"SELECT * FROM s3(nc_url_only, use_environment_credentials = 1, "
|
|
"format = 'TSV', structure = 'x UInt8')"
|
|
)
|
|
error = node.query_and_get_error(select_with_env)
|
|
assert "ACCESS_DENIED" in error, error
|
|
assert node.query(f"{select_with_env} {ALLOW}").strip() == "7"
|
|
|
|
|
|
def test_backup_named_collection_does_not_inherit_server_role():
|
|
node_with_server_role.query("DROP TABLE IF EXISTS t_backup SYNC")
|
|
node_with_server_role.query(
|
|
"CREATE TABLE t_backup (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_role.query("INSERT INTO t_backup SELECT 1")
|
|
|
|
# Control: the positional URL form inherits the global <s3> role_arn, a server-managed credential
|
|
# source, so the restriction rejects it. This also proves the role config is loaded and visible to
|
|
# backups.
|
|
error = node_with_server_role.query_and_get_error(
|
|
"BACKUP TABLE t_backup TO S3('http://minio1:9001/root/backup_positional/b1')"
|
|
)
|
|
assert "server-managed credentials" in error, error
|
|
|
|
# A URL-only backup named collection is a full auth override: the global role_arn must not leak into
|
|
# it, so the backup goes out unsigned and minio rejects the anonymous request instead.
|
|
error = node_with_server_role.query_and_get_error(
|
|
"BACKUP TABLE t_backup TO S3(nc_backup_url_only, 'b1')"
|
|
)
|
|
assert "server-managed credentials" not in error, error
|
|
assert "403" in error or "Access Denied" in error or "AccessDenied" in error, error
|
|
|
|
node_with_server_role.query("DROP TABLE t_backup SYNC")
|
|
|
|
|
|
def test_backup_named_collection_gcp_oauth_is_rejected():
|
|
# A backup named collection that itself asks for `http_client = gcp_oauth` without a complete explicit
|
|
# Google ADC triple must reach the central credential rejection -- it would otherwise mint a token from
|
|
# the server's GCP metadata service. It must not be silently downgraded to anonymous (the strip in
|
|
# makeS3Client only drops a `gcp_oauth` inherited from the server <s3> config, not one the collection
|
|
# supplied).
|
|
node.query("DROP TABLE IF EXISTS t_backup_gcp SYNC")
|
|
node.query("CREATE TABLE t_backup_gcp (x UInt8) ENGINE = MergeTree ORDER BY tuple()")
|
|
node.query("INSERT INTO t_backup_gcp SELECT 1")
|
|
|
|
error = node.query_and_get_error(
|
|
"BACKUP TABLE t_backup_gcp TO S3(nc_backup_gcp_oauth, 'b1')"
|
|
)
|
|
# The specific credential-restriction message proves it is the restriction rejecting it, not anonymous
|
|
# access failing or `gcp_oauth` being unsupported.
|
|
assert "gcp_oauth" in error and "not allowed to use" in error, error
|
|
|
|
node.query("DROP TABLE t_backup_gcp SYNC")
|
|
|
|
|
|
def test_named_collection_role_arn_override_does_not_use_collection_keys():
|
|
# A config collection carries operator-provisioned static keys. With named-collection overrides enabled
|
|
# (the default), a user must not be able to add `role_arn = ...` to assume an arbitrary role using those
|
|
# keys as the STS base. Under the restriction the injected role is dropped and the collection's keys are
|
|
# used directly (equivalent to s3(collection)).
|
|
url = "http://minio1:9001/root/nc_role_override/data.tsv"
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 5 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
# The injected role is dropped, so the read succeeds using the collection's own keys.
|
|
assert (
|
|
node.query(
|
|
"SELECT * FROM s3(nc_with_keys, role_arn = 'arn:aws:iam::123456789012:role/evil', "
|
|
"format = 'TSV', structure = 'x UInt8')"
|
|
).strip()
|
|
== "5"
|
|
)
|
|
|
|
# With the override enabled the injected role is honored, so the role assume is attempted (and fails,
|
|
# there is no STS endpoint) instead of silently reading with the collection keys. This proves the read
|
|
# above succeeded because the role was dropped, not because the role was harmlessly ignored.
|
|
error = node.query_and_get_error(
|
|
"SELECT * FROM s3(nc_with_keys, role_arn = 'arn:aws:iam::123456789012:role/evil', "
|
|
f"format = 'TSV', structure = 'x UInt8') {ALLOW}"
|
|
)
|
|
assert error, error
|
|
|
|
|
|
def test_database_s3_named_collection_use_environment_credentials():
|
|
# ENGINE = S3(named_collection) must apply the same restriction as the s3 table function it flattens into.
|
|
# A collection opting in with use_environment_credentials = 1 is refused by default and works with the
|
|
# session opt-in -- proving the key is whitelisted (no unexpected-key error) and carried through to the
|
|
# built s3() table function (not flattened away).
|
|
url = "http://minio1:9001/root/db_s3_env_test/data.tsv"
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 9 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
node.query("DROP DATABASE IF EXISTS db_s3_env SYNC")
|
|
node.query("CREATE DATABASE db_s3_env ENGINE = S3(nc_db_use_env)")
|
|
|
|
# Default restriction: building the s3() table resolves the server's ambient credentials, so it is refused.
|
|
error = node.query_and_get_error("SELECT * FROM db_s3_env.`data.tsv`")
|
|
assert "ACCESS_DENIED" in error, error
|
|
|
|
# With the session opt-in the ambient credentials are used and the read succeeds.
|
|
assert node.query(f"SELECT * FROM db_s3_env.`data.tsv` {ALLOW}").strip() == "9"
|
|
|
|
node.query("DROP DATABASE db_s3_env SYNC")
|
|
|
|
|
|
def test_explicit_keys_drop_inherited_session_token():
|
|
# The endpoint carries a (bogus) server session_token in <s3> config. A user query that supplies its own
|
|
# explicit key pair must not inherit and send that token -- otherwise minio rejects the request. The read
|
|
# succeeding proves the inherited token was dropped.
|
|
url = "http://minio1:9001/root/sessiontoken/data.tsv"
|
|
# Write with explicit keys from a node without the session_token config.
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 3 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
assert (
|
|
node_with_server_session_token.query(
|
|
f"SELECT * FROM s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8')"
|
|
).strip()
|
|
== "3"
|
|
)
|
|
|
|
|
|
def test_backup_explicit_keys_drop_inherited_session_token():
|
|
# BACKUP TO S3(url, ak, sk) with explicit keys must not send the server's inherited per-endpoint
|
|
# session_token (the explicit form has no way to supply one). The backup succeeding proves the token was
|
|
# dropped -- minio rejects the bogus inherited token if it is sent.
|
|
node_with_server_session_token.query("DROP TABLE IF EXISTS t_backup_token SYNC")
|
|
node_with_server_session_token.query(
|
|
"CREATE TABLE t_backup_token (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_session_token.query("INSERT INTO t_backup_token SELECT 1")
|
|
|
|
node_with_server_session_token.query(
|
|
f"BACKUP TABLE t_backup_token TO "
|
|
f"S3('http://minio1:9001/root/sessiontoken/backup1', '{minio_access_key}', '{minio_secret_key}')"
|
|
)
|
|
|
|
node_with_server_session_token.query("DROP TABLE t_backup_token SYNC")
|
|
|
|
|
|
def test_backup_role_arn_override_does_not_use_collection_keys():
|
|
# A query-overridden `role_arn` on a backup collection that carries operator-provisioned keys must not be
|
|
# assumed using those keys as the STS base. Under the restriction the injected role is dropped and the
|
|
# collection's keys are used directly, so the backup succeeds.
|
|
node.query("DROP TABLE IF EXISTS t_backup_override SYNC")
|
|
node.query("CREATE TABLE t_backup_override (x UInt8) ENGINE = MergeTree ORDER BY tuple()")
|
|
node.query("INSERT INTO t_backup_override SELECT 1")
|
|
|
|
node.query(
|
|
"BACKUP TABLE t_backup_override TO "
|
|
"S3(nc_backup_with_keys, role_arn = 'arn:aws:iam::123456789012:role/evil', 'b_override')"
|
|
)
|
|
|
|
# With the override enabled the injected role is honored, so the role assume is attempted (and fails) instead
|
|
# of silently using the collection keys -- proving the success above was the role being dropped.
|
|
error = node.query_and_get_error(
|
|
"BACKUP TABLE t_backup_override TO "
|
|
f"S3(nc_backup_with_keys, role_arn = 'arn:aws:iam::123456789012:role/evil', 'b_override_allowed') {ALLOW}"
|
|
)
|
|
assert error
|
|
|
|
node.query("DROP TABLE t_backup_override SYNC")
|
|
|
|
|
|
def test_backup_role_only_collection_is_rejected():
|
|
# A backup collection that defines only a `role_arn` (no base keys) would assume the role using the server's
|
|
# ambient credentials, so under the restriction it must reach the central rejection, not go anonymous.
|
|
node.query("DROP TABLE IF EXISTS t_backup_role_only SYNC")
|
|
node.query("CREATE TABLE t_backup_role_only (x UInt8) ENGINE = MergeTree ORDER BY tuple()")
|
|
node.query("INSERT INTO t_backup_role_only SELECT 1")
|
|
|
|
error = node.query_and_get_error(
|
|
"BACKUP TABLE t_backup_role_only TO S3(nc_backup_role_only, 'b_role_only')"
|
|
)
|
|
assert "server-managed credentials" in error, error
|
|
|
|
node.query("DROP TABLE t_backup_role_only SYNC")
|
|
|
|
|
|
def test_backup_named_collection_does_not_inherit_server_keys():
|
|
node_with_server_keys.query("DROP TABLE IF EXISTS t_backup_keys SYNC")
|
|
node_with_server_keys.query(
|
|
"CREATE TABLE t_backup_keys (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_keys.query("INSERT INTO t_backup_keys SELECT 1")
|
|
|
|
# Control: the positional URL form inherits the server <s3> static keys, so the backup succeeds. This
|
|
# proves the server keys are configured and usable for this endpoint (static keys are explicit
|
|
# credentials, not a server-managed mechanism, so the restriction does not block them).
|
|
node_with_server_keys.query(
|
|
"BACKUP TABLE t_backup_keys TO S3('http://minio1:9001/root/backup_keys_positional/b1')"
|
|
)
|
|
|
|
# A URL-only backup named collection is a full auth override: the server static keys must not leak into
|
|
# it, so the backup goes out unsigned and minio rejects the anonymous request instead.
|
|
error = node_with_server_keys.query_and_get_error(
|
|
"BACKUP TABLE t_backup_keys TO S3(nc_backup_url_only, 'b1')"
|
|
)
|
|
assert "403" in error or "Access Denied" in error or "AccessDenied" in error, error
|
|
|
|
# A backup named collection with only a role_arn has no base key pair of its own, so the server static
|
|
# keys must not be used as the STS base to assume the role. The role cannot be assumed and the backup
|
|
# fails (unsigned -> rejected) instead of authenticating with the server's keys.
|
|
error = node_with_server_keys.query_and_get_error(
|
|
"BACKUP TABLE t_backup_keys TO S3(nc_backup_role_only, 'b1')"
|
|
)
|
|
assert (
|
|
"403" in error
|
|
or "Access Denied" in error
|
|
or "AccessDenied" in error
|
|
or "server-managed credentials" in error
|
|
), error
|
|
|
|
node_with_server_keys.query("DROP TABLE t_backup_keys SYNC")
|
|
|
|
|
|
def test_backup_named_collection_non_overridable_key_cannot_be_redirected():
|
|
# A backup named collection with operator-provisioned keys and a non-overridable `url` must not let a user
|
|
# redirect the endpoint while keeping the collection's keys. The override permission is enforced like the
|
|
# table-function/storage paths (`tryGetNamedCollectionWithOverrides`), not bypassed -- otherwise a user
|
|
# could point the operator keys at an arbitrary endpoint.
|
|
node.query("DROP TABLE IF EXISTS t_backup_locked SYNC")
|
|
node.query("CREATE TABLE t_backup_locked (x UInt8) ENGINE = MergeTree ORDER BY tuple()")
|
|
node.query("INSERT INTO t_backup_locked SELECT 1")
|
|
|
|
error = node.query_and_get_error(
|
|
"BACKUP TABLE t_backup_locked TO "
|
|
"S3(nc_backup_locked_url, url = 'http://minio1:9001/root/backup_redirected/', 'b1')"
|
|
)
|
|
assert "Override not allowed" in error and "url" in error, error
|
|
|
|
node.query("DROP TABLE t_backup_locked SYNC")
|
|
|
|
|
|
def test_backup_explicit_keys_drop_inherited_gcp_oauth():
|
|
# The server <s3> endpoint config carries `gcp_oauth` plus a complete Google ADC triple. A BACKUP TO
|
|
# S3(url, ak, sk) with explicit keys must not inherit that gcp_oauth (which would mint a server bearer
|
|
# token to the user-chosen endpoint); it must use only the explicit keys. The backup succeeding proves the
|
|
# inherited gcp_oauth/ADC was dropped -- otherwise the GCP OAuth client is used instead of the keys and the
|
|
# backup fails. (The earlier strip only fired when the server config had no complete ADC triple.)
|
|
node_with_server_gcp_oauth.query("DROP TABLE IF EXISTS t_backup_gcp_inherit SYNC")
|
|
node_with_server_gcp_oauth.query(
|
|
"CREATE TABLE t_backup_gcp_inherit (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_gcp_oauth.query("INSERT INTO t_backup_gcp_inherit SELECT 1")
|
|
|
|
node_with_server_gcp_oauth.query(
|
|
f"BACKUP TABLE t_backup_gcp_inherit TO "
|
|
f"S3('http://minio1:9001/root/gcpoauth/backup1', '{minio_access_key}', '{minio_secret_key}')"
|
|
)
|
|
|
|
node_with_server_gcp_oauth.query("DROP TABLE t_backup_gcp_inherit SYNC")
|
|
|
|
|
|
def test_bare_url_s3_does_not_inherit_server_sse():
|
|
# The bare-URL `s3('url', key, secret)` form cannot supply request-auth material, so the server <s3>
|
|
# endpoint SSE-C key must not be inherited (it would be sent to the user-chosen endpoint). Reading a
|
|
# plaintext object with explicit keys must succeed; an inherited SSE-C key makes the GET fail.
|
|
url = "http://minio1:9001/root/sse_test/bare_url.tsv"
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 13 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
assert (
|
|
node_with_server_sse.query(
|
|
f"SELECT * FROM s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8')"
|
|
).strip()
|
|
== "13"
|
|
)
|
|
|
|
|
|
def test_backup_named_collection_does_not_inherit_server_sse():
|
|
# A backup named collection must not inherit the server <s3> SSE-C key: the backup objects are written
|
|
# without it, so the backup metadata object can be read back with plain keys. If the server SSE-C key leaked
|
|
# into the backup client, the objects would be encrypted with it and a plain GET would fail.
|
|
node_with_server_sse.query("DROP TABLE IF EXISTS t_backup_sse SYNC")
|
|
node_with_server_sse.query(
|
|
"CREATE TABLE t_backup_sse (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_sse.query("INSERT INTO t_backup_sse SELECT 1")
|
|
node_with_server_sse.query("BACKUP TABLE t_backup_sse TO S3(nc_backup_sse, 'b1')")
|
|
|
|
# Read the backup metadata object back with plain keys (no SSE-C headers): succeeds only if it was written
|
|
# without the server SSE-C key.
|
|
node_with_server_sse.query(
|
|
"SELECT count() FROM s3('http://minio1:9001/root/sse_test/backup_pv1/b1/.backup', "
|
|
f"'{minio_access_key}', '{minio_secret_key}', 'RawBLOB')"
|
|
)
|
|
|
|
node_with_server_sse.query("DROP TABLE t_backup_sse SYNC")
|
|
|
|
|
|
def test_backup_explicit_keys_do_not_inherit_server_sse():
|
|
# The explicit-key BACKUP TO S3(url, ak, sk) form (no named collection) must not inherit the server <s3>
|
|
# SSE-C key either: the backup objects are written without it and can be read back with plain keys. If the
|
|
# server SSE-C key leaked into the backup client, the objects would be encrypted and a plain GET would fail.
|
|
node_with_server_sse.query("DROP TABLE IF EXISTS t_backup_n1 SYNC")
|
|
node_with_server_sse.query(
|
|
"CREATE TABLE t_backup_n1 (x UInt8) ENGINE = MergeTree ORDER BY tuple()"
|
|
)
|
|
node_with_server_sse.query("INSERT INTO t_backup_n1 SELECT 1")
|
|
node_with_server_sse.query(
|
|
f"BACKUP TABLE t_backup_n1 TO S3('http://minio1:9001/root/sse_test/backup_n1/b1', "
|
|
f"'{minio_access_key}', '{minio_secret_key}')"
|
|
)
|
|
node_with_server_sse.query(
|
|
"SELECT count() FROM s3('http://minio1:9001/root/sse_test/backup_n1/b1/.backup', "
|
|
f"'{minio_access_key}', '{minio_secret_key}', 'RawBLOB')"
|
|
)
|
|
node_with_server_sse.query("DROP TABLE t_backup_n1 SYNC")
|
|
|
|
|
|
def test_dynamic_disk_include_locations_child_nosign_gcp_oauth_restricted():
|
|
# A `locations.<name>` child that resolves NOSIGN is anonymous for AWS signing, but if it also resolves
|
|
# http_client = gcp_oauth the GCP OAuth client still mints and sends the server metadata token. Such a child
|
|
# must be restricted, not treated as anonymous because of NOSIGN.
|
|
node_with_includes.query("DROP TABLE IF EXISTS t_loc_gcp SYNC")
|
|
error = node_with_includes.query_and_get_error(
|
|
"CREATE TABLE t_loc_gcp (x UInt8) ENGINE = MergeTree ORDER BY tuple() "
|
|
"SETTINGS disk = disk(type = object_storage, object_storage_type = local_blob_storage, "
|
|
"include = 'evil_locations_nosign_gcp')",
|
|
settings={"dynamic_disk_allow_include": 1},
|
|
)
|
|
assert "ACCESS_DENIED" in error or "must provide its credentials explicitly" in error, error
|
|
|
|
|
|
def test_dynamic_disk_include_locations_child_keys_not_vouched_by_root():
|
|
# A dummy explicit key pair on the disk root must not vouch for a `locations.<name>` S3 child whose
|
|
# credentials were resolved from the include/server config: the child's keys are not user-supplied SQL for
|
|
# that child. The post-include check validates credentials per resolved S3 backend, not from a root wrapper.
|
|
node_with_includes.query("DROP TABLE IF EXISTS t_loc_keys SYNC")
|
|
error = node_with_includes.query_and_get_error(
|
|
"CREATE TABLE t_loc_keys (x UInt8) ENGINE = MergeTree ORDER BY tuple() "
|
|
"SETTINGS disk = disk(type = object_storage, object_storage_type = local_blob_storage, "
|
|
"access_key_id = 'dummy_root', secret_access_key = 'dummy_root', include = 'evil_locations_with_keys')",
|
|
settings={"dynamic_disk_allow_include": 1},
|
|
)
|
|
assert "ACCESS_DENIED" in error or "must provide its credentials explicitly" in error, error
|
|
|
|
|
|
def test_dynamic_disk_include_locations_child_is_restricted():
|
|
# An `include` can inject a `locations.<name>` child object storage. A disk root that proves non-S3
|
|
# (object_storage with local_blob_storage) bypasses the pre-resolution check, so the post-`include`
|
|
# re-check must inspect each `locations.*` child and reject an S3 child that resolves server credentials --
|
|
# otherwise the child is built with `for_disk_s3 = true`, bypassing the central restriction.
|
|
node_with_includes.query("DROP TABLE IF EXISTS t_loc SYNC")
|
|
error = node_with_includes.query_and_get_error(
|
|
"CREATE TABLE t_loc (x UInt8) ENGINE = MergeTree ORDER BY tuple() "
|
|
"SETTINGS disk = disk(type = object_storage, object_storage_type = local_blob_storage, "
|
|
"include = 'evil_locations')",
|
|
settings={"dynamic_disk_allow_include": 1},
|
|
)
|
|
assert "ACCESS_DENIED" in error or "must provide its credentials explicitly" in error, error
|
|
|
|
|
|
def test_persistent_table_does_not_reuse_credentialed_client_across_sessions():
|
|
# A persistent S3 table whose credentials are server-managed (use_environment_credentials) is non-static, so
|
|
# its client is rebuilt per query. An opt-in session reading it builds a credentialed client in the shared
|
|
# object storage; a later restricted session must NOT reuse that client. The restriction mode is part of the
|
|
# client's identity, so the restricted session rebuilds an (anonymous) client and is still refused.
|
|
url = "http://minio1:9001/root/persistent_env/data.tsv"
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 7 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
node.query("DROP TABLE IF EXISTS t_persistent_env SYNC")
|
|
# The S3 engine resolves credentials at CREATE, so create it with the opt-in (the operator owns the table).
|
|
node.query(
|
|
"CREATE TABLE t_persistent_env (x UInt8) ENGINE = S3(nc_persistent_env)",
|
|
settings={"s3_allow_server_credentials_in_user_queries": 1},
|
|
)
|
|
|
|
# Opt-in: succeeds, building a credentialed client in the shared object storage.
|
|
assert node.query(f"SELECT * FROM t_persistent_env {ALLOW}").strip() == "7"
|
|
# Restricted (default): must be refused -- the credentialed client primed by the opt-in session above is not
|
|
# reused across the restriction-mode change.
|
|
assert "ACCESS_DENIED" in node.query_and_get_error("SELECT * FROM t_persistent_env")
|
|
# Opt-in again: still works (the restricted query did not poison the storage either).
|
|
assert node.query(f"SELECT * FROM t_persistent_env {ALLOW}").strip() == "7"
|
|
|
|
node.query("DROP TABLE t_persistent_env SYNC")
|
|
|
|
|
|
def test_persistent_table_created_with_opt_in_refuses_first_restricted_read():
|
|
# A persistent S3 table created with the opt-in resolves the server credentials at create time. The FIRST
|
|
# read in a default restricted session must still be refused -- the create-time client is not reused across
|
|
# the restriction-mode change (the create-time settings update establishes the mode, so the restricted read
|
|
# rebuilds anonymous and is refused).
|
|
node.query("DROP TABLE IF EXISTS t_persist_optin SYNC")
|
|
node.query(
|
|
"CREATE TABLE t_persist_optin (x UInt8) ENGINE = S3(nc_persistent_env)",
|
|
settings={"s3_allow_server_credentials_in_user_queries": 1},
|
|
)
|
|
assert "ACCESS_DENIED" in node.query_and_get_error("SELECT * FROM t_persist_optin")
|
|
node.query("DROP TABLE t_persist_optin SYNC")
|
|
|
|
|
|
def test_named_collection_does_not_inherit_server_sse():
|
|
# The server <s3> endpoint config carries an SSE-C customer key. A named collection with its own explicit
|
|
# keys must not inherit that server-managed key (which getClient would send to the endpoint): reading a
|
|
# plaintext object via the collection must succeed. If the SSE-C key leaks in, the GET sends SSE-C headers
|
|
# for an object that is not SSE-C encrypted and S3 rejects it -- and the server's key would reach the
|
|
# collection's endpoint.
|
|
url = "http://minio1:9001/root/sse_test/data.tsv"
|
|
# Write a plaintext object using explicit keys from a node without the SSE config.
|
|
node.query(
|
|
f"INSERT INTO FUNCTION s3('{url}', '{minio_access_key}', '{minio_secret_key}', 'TSV', 'x UInt8') "
|
|
"SELECT 11 SETTINGS s3_truncate_on_insert = 1"
|
|
)
|
|
|
|
assert node_with_server_sse.query("SELECT * FROM s3(nc_sse_keys)").strip() == "11"
|
|
|
|
|
|
def test_server_data_disk_unaffected():
|
|
# The server's own S3-backed operations are not user queries and keep working regardless.
|
|
node.query("DROP TABLE IF EXISTS t_local SYNC")
|
|
node.query("CREATE TABLE t_local (x UInt8) ENGINE = MergeTree ORDER BY tuple()")
|
|
node.query("INSERT INTO t_local SELECT 4")
|
|
assert node.query("SELECT sum(x) FROM t_local").strip() == "4"
|
|
node.query("DROP TABLE t_local SYNC")
|