mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
417 lines
23 KiB
Python
417 lines
23 KiB
Python
"""The Hanzo IAM user directory end to end: tokens from a stand-in issuer, the image's own directory
|
|
section and bootstrap roles, and the event-plane schema (hanzoai/o11y
|
|
deploy/datastore/migrations/0005_iam_roles.sql) applied by the schema's owning application under
|
|
its own token, with no password anywhere.
|
|
"""
|
|
|
|
import os
|
|
import shlex
|
|
import typing
|
|
import urllib.parse
|
|
|
|
import pytest
|
|
|
|
from helpers.cluster import ClickHouseCluster
|
|
from helpers.test_tools import wait_condition
|
|
|
|
cluster = ClickHouseCluster(__file__)
|
|
instance = cluster.add_instance(
|
|
"node",
|
|
# The image's own directory section and bootstrap roles, not copies of them; the overlay
|
|
# points the issuer at iam_server.py and changes nothing else.
|
|
main_configs=["../../../docker/server/datastore-iam.xml", "configs/test-issuer.xml"],
|
|
user_configs=["../../../docker/server/datastore-iam-roles.xml"],
|
|
)
|
|
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
|
|
# The event plane and its rows, as the o11y application.
|
|
PLANE = [
|
|
"CREATE DATABASE IF NOT EXISTS event",
|
|
"CREATE TABLE IF NOT EXISTS event.fact (org LowCardinality(String), id String) ENGINE = MergeTree ORDER BY (org, id)",
|
|
"CREATE TABLE IF NOT EXISTS event.sample (org LowCardinality(String), value Float64) ENGINE = MergeTree ORDER BY org",
|
|
"INSERT INTO event.fact VALUES ('acme', 'a1'), ('acme', 'a2'), ('zoo', 'z1'), ('', 'none')",
|
|
]
|
|
|
|
# The statements of hanzoai/o11y deploy/datastore/migrations/0005_iam_roles.sql, in its order: roles,
|
|
# then the policies that confine them, then the grants.
|
|
MIGRATION = [
|
|
"CREATE ROLE IF NOT EXISTS tenant",
|
|
"CREATE ROLE IF NOT EXISTS reader",
|
|
"CREATE ROLE IF NOT EXISTS writer",
|
|
"CREATE ROLE IF NOT EXISTS sink",
|
|
"CREATE ROW POLICY OR REPLACE tenant ON event.* USING org = currentOrg() AS PERMISSIVE TO tenant",
|
|
"CREATE ROW POLICY OR REPLACE superadmin ON event.* USING 1 AS PERMISSIVE TO superadmin",
|
|
"CREATE ROW POLICY OR REPLACE schema ON event.* USING 1 AS PERMISSIVE TO schema",
|
|
"GRANT SELECT ON event.* TO reader",
|
|
"GRANT INSERT ON event.* TO writer",
|
|
"REVOKE INSERT ON event.fact FROM writer",
|
|
"GRANT INSERT ON event.fact TO sink",
|
|
"""SELECT throwIf(
|
|
(SELECT count() FROM system.roles
|
|
WHERE name IN ('tenant', 'reader', 'writer', 'sink', 'schema', 'platform', 'superadmin')) != 7
|
|
OR (SELECT count() FROM system.row_policies WHERE database = 'event') != 3
|
|
OR (SELECT count() FROM system.row_policies
|
|
WHERE database = 'event' AND table = ''
|
|
AND ((short_name = 'tenant' AND select_filter = 'org = currentOrg()' AND apply_to_list = ['tenant'])
|
|
OR (short_name = 'superadmin' AND select_filter = '1' AND apply_to_list = ['superadmin'])
|
|
OR (short_name = 'schema' AND select_filter = '1' AND apply_to_list = ['schema']))) != 3,
|
|
'The IAM roles or the event.* row policies are not as this file declares them.') AS ok""",
|
|
]
|
|
|
|
# The four event rows every test starts from, by org: acme 2, zoo 1, and one with no org.
|
|
FACTS = 4
|
|
|
|
|
|
def token(**claims: str) -> str:
|
|
query = urllib.parse.urlencode(claims)
|
|
return instance.exec_in_container(["curl", "-sf", f"http://localhost:8000/token?{query}"])
|
|
|
|
|
|
def person(sub: str, orgs: str, **claims: str) -> str:
|
|
return token(sub=sub, orgs=orgs, **claims)
|
|
|
|
|
|
def program(application: str, **claims: str) -> str:
|
|
"""A client_credentials token of the platform's application row admin/<application>, as IAM mints it."""
|
|
return token(sub=f"admin/{application}", owner="hanzo", azp=application, type="application", **claims)
|
|
|
|
|
|
def confined(bearer: str) -> None:
|
|
"""A person of acme reads acme's rows, or is refused; never another org's."""
|
|
code, body = http("SELECT org, count() FROM event.fact GROUP BY org ORDER BY org FORMAT TSV", bearer)
|
|
assert code != "200" or body in ("", "acme\t2\n"), body
|
|
|
|
|
|
def http(sql: str, bearer: str, extra: str = "") -> typing.Tuple[str, str]:
|
|
"""(status code, body) of a query over HTTP with `Authorization: Bearer`."""
|
|
command = (
|
|
"curl -s -w '\\n%{http_code}' -H "
|
|
+ shlex.quote(f"Authorization: Bearer {bearer}")
|
|
+ " --data-binary "
|
|
+ shlex.quote(sql)
|
|
+ " "
|
|
+ shlex.quote(f"http://localhost:8123/{extra}")
|
|
)
|
|
out = instance.exec_in_container(["bash", "-c", command])
|
|
body, _, code = out.rpartition("\n")
|
|
return code, body
|
|
|
|
|
|
def ok(sql: str, bearer: str, extra: str = "") -> str:
|
|
code, body = http(sql, bearer, extra)
|
|
assert code == "200", f"{sql}: {body}"
|
|
return body
|
|
|
|
|
|
def denied(sql: str, bearer: str) -> str:
|
|
code, body = http(sql, bearer)
|
|
assert code != "200", f"{sql} was allowed: {body}"
|
|
return body
|
|
|
|
|
|
def native(sql: str, jwt: str) -> str:
|
|
"""Queries over the native protocol, the client logging in with `--jwt`."""
|
|
return instance.exec_in_container(
|
|
["bash", "-c", f"/usr/bin/clickhouse client --jwt {shlex.quote(jwt)} --query {shlex.quote(sql)}"]
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def started_cluster() -> typing.Generator[ClickHouseCluster, None, None]:
|
|
try:
|
|
cluster.start()
|
|
instance.copy_file_to_container(os.path.join(SCRIPT_DIR, "iam_server.py"), "/iam_server.py")
|
|
instance.exec_in_container(
|
|
["bash", "-c", "python3 /iam_server.py > /var/log/clickhouse-server/iam_server.log 2>&1"],
|
|
detach=True,
|
|
user="root",
|
|
)
|
|
wait_condition(
|
|
lambda: instance.exec_in_container(["curl", "-s", "http://localhost:8000/health"], nothrow=True),
|
|
lambda response: response == "OK",
|
|
max_attempts=40,
|
|
delay=0.5,
|
|
)
|
|
# The host creates the database it owns.
|
|
ok("CREATE DATABASE IF NOT EXISTS hanzo", program("hanzo-cloud"))
|
|
# The schema's owner applies its own migration as its own identity. Wherever the run stops, a
|
|
# person reads their own org's rows or none: never every org's.
|
|
schema = program("hanzo-o11y")
|
|
for statement in PLANE:
|
|
ok(statement, schema)
|
|
for statement in MIGRATION:
|
|
ok(statement, schema)
|
|
confined(person("acme/alice", "acme"))
|
|
yield cluster
|
|
finally:
|
|
cluster.shutdown()
|
|
|
|
|
|
def test_person_sees_only_their_home_org(started_cluster):
|
|
by_org = "SELECT org, count() FROM event.fact GROUP BY org ORDER BY org FORMAT TSV"
|
|
assert ok(by_org, person("acme/alice", "acme")) == "acme\t2\n"
|
|
assert ok(by_org, person("zoo/bob", "zoo,acme")) == "zoo\t1\n"
|
|
|
|
|
|
def test_owner_decides_nothing(started_cluster):
|
|
# Signing in through an application the admin org owns, under the admin org's key: the tenant is
|
|
# still the home org, and it is no SuperAdmin.
|
|
bearer = person("acme/alice", "acme", owner="admin", kid="cert-admin")
|
|
assert ok("SELECT count() FROM event.fact", bearer) == "2\n"
|
|
assert ok("SELECT currentOrg(), has(currentRoles(), 'superadmin') FORMAT TSV", bearer) == "acme\t0\n"
|
|
|
|
|
|
def test_token_without_memberships_reads_nothing(started_cluster):
|
|
# No membership set: no org, and not a person, so not even a reader.
|
|
bearer = token(sub="acme/carol", owner="acme")
|
|
assert ok("SELECT currentOrg() IS NULL, arraySort(currentRoles()) FORMAT TSV", bearer) == "1\t['tenant']\n"
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM event.fact", bearer)
|
|
|
|
|
|
def test_superadmin_is_home_admin_under_the_admin_key(started_cluster):
|
|
assert ok("SELECT count() FROM event.fact", person("admin/z", "admin", kid="cert-admin")) == f"{FACTS}\n"
|
|
# The same claims under another org's key are an ordinary admin-org person: no rows but admin's.
|
|
assert ok("SELECT count() FROM event.fact", person("admin/z", "admin", kid="cert-hanzo")) == "0\n"
|
|
# An admin membership next to a brand home org is not SuperAdmin either.
|
|
assert ok("SELECT count() FROM event.fact", person("hanzo/z", "hanzo,admin", kid="cert-admin")) == "0\n"
|
|
|
|
|
|
def test_identity_is_the_token(started_cluster):
|
|
body = ok("SELECT currentUser(), currentOrg(), arraySort(currentRoles()) FORMAT TSV", person("acme/alice", "acme"))
|
|
assert body == "acme/alice\tacme\t['reader','tenant']\n"
|
|
# The ephemeral user can be shown; it has no stored credential to show.
|
|
assert "IDENTIFIED WITH jwt" in ok("SHOW CREATE USER `acme/alice`", person("admin/z", "admin", kid="cert-admin"))
|
|
|
|
|
|
def test_event_app_inserts_facts_and_reads_nothing(started_cluster):
|
|
event = program("hanzo-event")
|
|
assert ok("SELECT arraySort(currentRoles()) FORMAT TSV", event) == "['sink','tenant']\n"
|
|
ok("INSERT INTO event.fact VALUES ('hanzo', 'h1')", event)
|
|
# INSERT on event.fact and nothing else: no read of any org's rows, no other table.
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM event.fact", event)
|
|
assert "ACCESS_DENIED" in denied("INSERT INTO event.sample VALUES ('hanzo', 1)", event)
|
|
assert "ACCESS_DENIED" in denied("ALTER TABLE event.fact DELETE WHERE 1", event)
|
|
ok("DELETE FROM event.fact WHERE id = 'h1'", program("hanzo-o11y"))
|
|
|
|
|
|
def test_telemetry_writer_writes_everything_but_the_facts(started_cluster):
|
|
# `writer` is every telemetry table in the plane but event.fact. (The o11y application also
|
|
# holds `schema`, whose migrations may backfill any event table, facts included.)
|
|
superadmin = person("admin/z", "admin", kid="cert-admin")
|
|
grants = ok("SHOW GRANTS FOR writer FORMAT TSV", superadmin)
|
|
assert grants == "GRANT INSERT ON event.* TO writer\nREVOKE INSERT ON event.fact FROM writer\n", grants
|
|
|
|
|
|
def test_no_other_application_is_the_sink(started_cluster):
|
|
# A tenant that registered the client id hanzo-event in its own org first: IAM signs its own row,
|
|
# evilcorp/hanzo-event, as `sub`. A client id alone names nobody.
|
|
squatter = token(sub="evilcorp/hanzo-event", owner="evilcorp", azp="hanzo-event", type="application", orgs="evilcorp")
|
|
assert ok("SELECT arraySort(currentRoles()) FORMAT TSV", squatter) == "['tenant']\n"
|
|
for bearer in [
|
|
squatter,
|
|
program("hanzo-cloud"),
|
|
# The platform's row held by another client (a delegated or exchanged token).
|
|
token(sub="admin/hanzo-event", owner="hanzo", azp="hanzo-sandbox", type="application"),
|
|
# A person signed in through the event app's application is a reader, not the sink.
|
|
person("hanzo/dan", "hanzo", azp="hanzo-event"),
|
|
# A token that calls itself a program in any word but IAM's is no program.
|
|
token(sub="admin/hanzo-event", azp="hanzo-event", type="program"),
|
|
]:
|
|
assert "ACCESS_DENIED" in denied("INSERT INTO event.fact VALUES ('hanzo', 'x')", bearer)
|
|
# Nor does the squatter read anything, its own org's rows included: it holds no SELECT.
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM event.fact", squatter)
|
|
|
|
|
|
def test_platform_owns_the_hanzo_database(started_cluster):
|
|
host = program("hanzo-cloud")
|
|
assert ok("SELECT arraySort(currentRoles()) FORMAT TSV", host) == "['platform','tenant']\n"
|
|
ok("CREATE TABLE IF NOT EXISTS hanzo.cloud_usage (organization String, tokens UInt32) ENGINE = MergeTree ORDER BY organization", host)
|
|
ok("INSERT INTO hanzo.cloud_usage VALUES ('acme', 10), ('zoo', 20)", host)
|
|
ok("ALTER TABLE hanzo.cloud_usage ADD COLUMN IF NOT EXISTS model String", host)
|
|
# The event plane it neither reads nor writes nor reshapes: an org's read is that org's token.
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM event.fact", host)
|
|
assert "ACCESS_DENIED" in denied("INSERT INTO event.fact VALUES ('hanzo', 'x')", host)
|
|
assert "ACCESS_DENIED" in denied("CREATE TABLE event.escape (org String) ENGINE = Memory", host)
|
|
assert "ACCESS_DENIED" in denied("CREATE TABLE default.escape (x UInt8) ENGINE = Memory", host)
|
|
assert "ACCESS_DENIED" in denied("CREATE ROLE intruder", host)
|
|
assert "ACCESS_DENIED" in denied("GRANT SELECT ON event.* TO sink", host)
|
|
|
|
# It confines its own org-keyed tables for the reads cloud makes as a person's org, the way
|
|
# 0005 confines the event plane: the tenant's rows only, every row for itself, and only then
|
|
# SELECT for readers.
|
|
ok("CREATE ROW POLICY OR REPLACE tenant ON hanzo.cloud_usage USING organization = currentOrg() AS PERMISSIVE TO tenant", host)
|
|
ok("CREATE ROW POLICY OR REPLACE platform ON hanzo.* USING 1 AS PERMISSIVE TO platform", host)
|
|
ok("GRANT SELECT ON hanzo.cloud_usage TO reader", host)
|
|
assert ok("SELECT organization, sum(tokens) FROM hanzo.cloud_usage GROUP BY organization FORMAT TSV", person("acme/alice", "acme")) == "acme\t10\n"
|
|
assert ok("SELECT sum(tokens) FROM hanzo.cloud_usage", host) == "30\n"
|
|
# Nobody else touches the host's database.
|
|
for other in [program("hanzo-event"), program("hanzo-o11y")]:
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM hanzo.cloud_usage", other)
|
|
|
|
|
|
def test_a_view_an_identity_defines_holds_no_authority_of_its_own(started_cluster):
|
|
# The token's roles are the session's. A materialized view defined under them gets a stored
|
|
# definer, `<sub>:definer`, with none of them, so it cannot carry the session's authority past
|
|
# the session: an insert into its source is refused instead.
|
|
host = program("hanzo-cloud")
|
|
ok("CREATE TABLE hanzo.definer_source (organization String, tokens UInt32) ENGINE = MergeTree ORDER BY organization", host)
|
|
ok("CREATE TABLE hanzo.definer_target (organization String, tokens UInt64) ENGINE = SummingMergeTree ORDER BY organization", host)
|
|
ok("CREATE MATERIALIZED VIEW hanzo.definer_mv TO hanzo.definer_target AS SELECT organization, toUInt64(tokens) AS tokens FROM hanzo.definer_source", host)
|
|
assert "admin/hanzo-cloud:definer" in ok("SHOW CREATE TABLE hanzo.definer_mv", host)
|
|
assert "ACCESS_DENIED" in denied("INSERT INTO hanzo.definer_source VALUES ('acme', 1)", host)
|
|
ok("DROP TABLE hanzo.definer_mv", host)
|
|
ok("INSERT INTO hanzo.definer_source VALUES ('acme', 1)", host)
|
|
ok("DROP TABLE hanzo.definer_source", host)
|
|
ok("DROP TABLE hanzo.definer_target", host)
|
|
|
|
|
|
def test_schema_role_is_scoped_to_its_database(started_cluster):
|
|
schema = program("hanzo-o11y")
|
|
# Its own plane, every row: a migration that backfills must see them all.
|
|
assert ok("SELECT count() FROM event.fact", schema) == f"{FACTS}\n"
|
|
# Nothing outside it, and no role it does not own.
|
|
assert "ACCESS_DENIED" in denied("CREATE TABLE default.escape (x UInt8) ENGINE = Memory", schema)
|
|
assert "ACCESS_DENIED" in denied("CREATE TABLE hanzo.escape (x UInt8) ENGINE = Memory", schema)
|
|
assert "ACCESS_DENIED" in denied("CREATE ROLE intruder", schema)
|
|
assert "ACCESS_DENIED" in denied("GRANT superadmin TO reader", schema)
|
|
assert "ACCESS_DENIED" in denied("GRANT SELECT ON system.* TO reader", schema)
|
|
|
|
|
|
def test_no_object_reaches_past_its_database(started_cluster):
|
|
# A dictionary's source logs in as a user of its own choosing (by default the loopback `default`),
|
|
# and Distributed, Merge, Buffer, URL and the database engines read another database or server.
|
|
# Neither the schema's owner nor the host may create one: no CREATE DICTIONARY, and table engines
|
|
# need a grant (datastore-iam.xml), of which they hold only the MergeTree family and Null.
|
|
host = program("hanzo-cloud")
|
|
ok("CREATE TABLE IF NOT EXISTS hanzo.cloud_usage (organization String, tokens UInt32) ENGINE = MergeTree ORDER BY organization", host)
|
|
for bearer, own, other, table in [
|
|
(program("hanzo-o11y"), "event", "hanzo", "cloud_usage"),
|
|
(host, "hanzo", "event", "fact"),
|
|
]:
|
|
for sql in [
|
|
f"CREATE DICTIONARY {own}.reach (k String, v String) PRIMARY KEY k SOURCE(DATASTORE(DB '{other}' TABLE '{table}')) LAYOUT(COMPLEX_KEY_HASHED()) LIFETIME(0)",
|
|
f"CREATE DICTIONARY {own}.reach (k String, v String) PRIMARY KEY k SOURCE(HTTP(URL 'http://localhost:8123/' FORMAT 'TabSeparated')) LAYOUT(COMPLEX_KEY_HASHED()) LIFETIME(0)",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = Distributed('default', '{other}', '{table}')",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = Merge('{other}', '.*')",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = Buffer('{other}', '{table}', 1, 10, 100, 10000, 1000000, 10000000, 100000000)",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = URL('http://localhost:8123/', 'TabSeparated')",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = PostgreSQL('localhost:9005', '{other}', '{table}', 'default', '')",
|
|
f"CREATE TABLE {own}.reach (k String) ENGINE = Memory",
|
|
f"CREATE TABLE {own}.reach AS remote('localhost', '{other}', '{table}')",
|
|
f"CREATE DATABASE IF NOT EXISTS {own} ENGINE = MySQL('localhost:9004', '{other}', 'default', '')",
|
|
]:
|
|
assert "ACCESS_DENIED" in denied(sql, bearer), sql
|
|
assert ok(f"SELECT count() FROM system.tables WHERE database = '{own}' AND name = 'reach'", bearer) == "0\n"
|
|
# What the migrations and the runners create is still theirs.
|
|
schema = program("hanzo-o11y")
|
|
for engine in ["MergeTree", "ReplacingMergeTree", "AggregatingMergeTree", "SummingMergeTree"]:
|
|
ok(f"CREATE TABLE event.engine_{engine} (org LowCardinality(String), v UInt64) ENGINE = {engine} ORDER BY org", schema)
|
|
ok(f"DROP TABLE event.engine_{engine}", schema)
|
|
ok("CREATE TABLE event.engine_null (held UInt8) ENGINE = Null", schema)
|
|
ok("DROP TABLE event.engine_null", schema)
|
|
|
|
|
|
def test_schema_keeps_the_migration_ledger(started_cluster):
|
|
# What cloud's datastore.Migrate runs, as the o11y application, in the schema's own database: the
|
|
# ledger (org-first, like every table of the plane), then one lock generation held, renewed and
|
|
# released. It needs no right outside `event`.
|
|
schema = program("hanzo-o11y")
|
|
ok("CREATE DATABASE IF NOT EXISTS event", schema)
|
|
ok(
|
|
"CREATE TABLE IF NOT EXISTS event.migration (org LowCardinality(String) DEFAULT '', set LowCardinality(String), "
|
|
"version UInt32, name String, sum String, by String, at DateTime64(3) DEFAULT now64(3)) "
|
|
"ENGINE = ReplacingMergeTree(at) ORDER BY (org, set, version)",
|
|
schema,
|
|
)
|
|
lock = "SELECT name, comment FROM system.tables WHERE database = 'event' AND startsWith(name, 'migration_lock_o11y_') FORMAT TSV"
|
|
assert ok(lock, schema) == ""
|
|
ok("CREATE TABLE event.migration_lock_o11y_1 (held UInt8) ENGINE = Null COMMENT 'o11y-test until 0'", schema)
|
|
assert ok(lock, schema) == "migration_lock_o11y_1\to11y-test until 0\n"
|
|
ok("ALTER TABLE event.migration_lock_o11y_1 MODIFY COMMENT 'o11y-test until 1'", schema)
|
|
ok("SELECT toUnixTimestamp(now()) AS now", schema)
|
|
ok("INSERT INTO event.migration (set, version, name, sum, by) VALUES ('o11y', 5, '0005_iam_roles.sql', 'digest', 'o11y-test')", schema)
|
|
assert ok("SELECT version, sum FROM event.migration FINAL WHERE set = 'o11y' FORMAT TSV", schema) == "5\tdigest\n"
|
|
ok("DROP TABLE IF EXISTS event.migration_lock_o11y_1", schema)
|
|
# A tenant sees none of the ledger (its org is ''), and the host does not touch it.
|
|
assert ok("SELECT count() FROM event.migration", person("acme/alice", "acme")) == "0\n"
|
|
assert "ACCESS_DENIED" in denied("SELECT count() FROM event.migration", program("hanzo-cloud"))
|
|
# Nothing of the host's database is the schema's.
|
|
assert "ACCESS_DENIED" in denied("CREATE TABLE IF NOT EXISTS hanzo.migration (x UInt8) ENGINE = Memory", schema)
|
|
|
|
|
|
def test_grants_reach_only_the_planes_roles(started_cluster):
|
|
# The schema's grant option (and the host's) may name reader, writer and sink, and nobody else: not a
|
|
# user of another directory, not another role. SuperAdmin's may name anyone.
|
|
superadmin = person("admin/z", "admin", kid="cert-admin")
|
|
ok("CREATE ROLE IF NOT EXISTS outsider", superadmin)
|
|
ok("CREATE USER IF NOT EXISTS legacy HOST NONE", superadmin)
|
|
schema = program("hanzo-o11y")
|
|
for grantee in ["legacy", "outsider", "tenant", "legacy, reader"]:
|
|
assert "is not allowed as grantee" in denied(f"GRANT SELECT ON event.* TO {grantee}", schema), grantee
|
|
assert "is not allowed as grantee" in denied("GRANT SELECT ON hanzo.* TO legacy", program("hanzo-cloud"))
|
|
ok("GRANT SELECT ON event.fact TO reader", schema)
|
|
assert ok("SHOW GRANTS FOR legacy FORMAT TSV", superadmin) == ""
|
|
assert ok("SHOW GRANTS FOR outsider FORMAT TSV", superadmin) == ""
|
|
ok("GRANT SELECT ON event.fact TO outsider", superadmin)
|
|
ok("DROP USER legacy", superadmin)
|
|
ok("DROP ROLE outsider", superadmin)
|
|
|
|
|
|
def test_migration_in_one_session_grants_to_the_roles_it_creates(started_cluster):
|
|
# Cloud's runner applies a migration over one pooled connection, logged in before the migration
|
|
# creates its roles. The roles it creates are grantees of that login from the moment they exist.
|
|
superadmin = person("admin/z", "admin", kid="cert-admin")
|
|
ok("DROP ROLE IF EXISTS reader, writer, sink", superadmin)
|
|
native(";\n".join(MIGRATION), program("hanzo-o11y"))
|
|
assert ok("SHOW GRANTS FOR reader FORMAT TSV", superadmin) == "GRANT SELECT ON event.* TO reader\n"
|
|
assert ok("SHOW GRANTS FOR writer FORMAT TSV", superadmin) == (
|
|
"GRANT INSERT ON event.* TO writer\nREVOKE INSERT ON event.fact FROM writer\n"
|
|
)
|
|
assert ok("SHOW GRANTS FOR sink FORMAT TSV", superadmin) == "GRANT INSERT ON event.fact TO sink\n"
|
|
assert ok("SELECT count() FROM event.fact", person("acme/alice", "acme")) == "2\n"
|
|
|
|
|
|
def test_query_cache_keeps_sessions_apart(started_cluster):
|
|
# One subject, two tokens: SuperAdmin under the admin key, an ordinary admin-org person under
|
|
# another. A result cached by the first must not answer the second.
|
|
query = "SELECT count() FROM event.fact"
|
|
cached = "?use_query_cache=1&query_cache_share_between_users=1"
|
|
assert ok(query, person("admin/z", "admin", kid="cert-admin"), cached) == f"{FACTS}\n"
|
|
assert ok(query, person("admin/z", "admin", kid="cert-hanzo"), cached) == "0\n"
|
|
assert ok(query, person("admin/y", "admin", kid="cert-hanzo"), cached) == "0\n"
|
|
# And an org's shared entry answers only that org.
|
|
assert ok(query, person("acme/alice", "acme"), cached) == "2\n"
|
|
assert ok(query, person("zoo/bob", "zoo"), cached) == "1\n"
|
|
|
|
|
|
def test_rejected_tokens(started_cluster):
|
|
for bad in [
|
|
person("acme/alice", "acme", aud="console"),
|
|
person("acme/alice", "acme", exp_in="-3600"),
|
|
person("acme/alice", "acme", kid="cert-unknown"),
|
|
person("acme/alice", "acme", iss="http://evil.test"),
|
|
person("acme/alice", "acme")[:-4] + "AAAA",
|
|
"not-a-token",
|
|
]:
|
|
code, body = http("SELECT 1", bad)
|
|
assert code == "516" or "AUTHENTICATION_FAILED" in body, body
|
|
|
|
|
|
def test_bearer_does_not_mix_with_a_password(started_cluster):
|
|
code, body = http("SELECT currentUser()", person("acme/alice", "acme"), "?user=default")
|
|
assert code != "200" and "not allowed to use" in body, body
|
|
|
|
|
|
def test_native_protocol_jwt(started_cluster):
|
|
bob = person("zoo/bob", "zoo")
|
|
assert native("SELECT currentUser(), currentOrg() FORMAT TSV", bob) == "zoo/bob\tzoo\n"
|
|
assert native("SELECT count() FROM event.fact", bob) == "1\n"
|
|
|
|
|
|
def test_session_roles_cannot_be_shed(started_cluster):
|
|
# The token's roles are the session's, not grants of the user: SET ROLE cannot drop `tenant`
|
|
# and with it the confinement.
|
|
bob = person("zoo/bob", "zoo")
|
|
assert native("SET ROLE NONE; SELECT count() FROM event.fact", bob) == "1\n"
|
|
assert native("SET ROLE DEFAULT; SELECT count() FROM event.fact", bob) == "1\n"
|