This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new aef7acaf84 [#11212] test(authz): add CI workflow for multi-instance 
consistency (#11213)
aef7acaf84 is described below

commit aef7acaf84f17d92110c464c3c62892096d61d44
Author: Qi Yu <[email protected]>
AuthorDate: Wed Jun 3 14:33:50 2026 +0800

    [#11212] test(authz): add CI workflow for multi-instance consistency 
(#11213)
    
    ### What changes were proposed in this pull request?
    
    Adds a new GitHub Actions workflow at
    \`.github/workflows/multi-instance-consistency-test.yml\`
    that brings up two Gravitino instances backed by a shared MySQL service
    container and runs
    the shell-based consistency suite against them.
    
    The complex instance-staging logic (JDBC download, conf patching,
    PID-grep fix, server
    start + health-wait) lives in \`dev/ci/setup_multi_instance.sh\`,
    following the same pattern
    as \`util_free_space.sh\` and \`test_multi_instance_consistency.sh\`.
    The YAML step is a single
    \`bash dev/ci/setup_multi_instance.sh\` call.
    
    The consistency suite at \`dev/ci/test_multi_instance_consistency.sh\`
    is a 19-phase /
    128-assertion script that exercises the \`JcasbinAuthorizer\` caches
    (\`ownerRelCache\`,
    \`metadataIdCache\`, \`userRoleCache\`, \`loadedRoles\`,
    \`groupRoleCache\`) and the
    \`JcasbinChangePoller\` invalidation paths across two instances:
    
    | Phase | What it covers |
    |---|---|
    | A / B' | Owner read-back + warm-cache invalidation (eventual,
    poll-driven) |
    | C / D | Role grant / revoke immediately effective on the peer |
    | E | Role-privilege add/remove immediately effective on the peer |
    | F | Role hard-delete clears peer's \`loadedRoles\` cache |
    | G / H | User delete + same-name re-create has no stale role
    inheritance |
    | I | Role re-create with same name has no stale privilege leak |
    | J | Cross-metalake isolation (role/owner does not leak) |
    | K | DENY condition overrides ALLOW on a different role |
    | L | Group lifecycle: create / grant / revoke / delete visible on peer
    |
    | N | \`setOwner\` propagation works for non-METALAKE entities (TAG) |
    | O | Multi-role partial revoke only affects the revoked role |
    | P | Owner change on TAG with warm cache on the peer |
    | Q | Burst of \`setOwner\` calls within one poll window converges |
    | R | Re-granting an already-held role is idempotent |
    | S | GROUP-type owner propagated correctly via \`ownerRelCache\` |
    | T | Tag delete + same-name re-create has no stale owner (owner_meta
    invalidation) |
    | U | GET \`/users/{user}\` on peer reflects role grant/revoke from the
    other instance |
    | V | GET \`/roles/{role}\` on peer reflects privilege changes from the
    other instance |
    | W | Bidirectional: grant/revoke on B is immediately effective on A |
    
    Both Gravitino instances run with:
    
    \`\`\`
    gravitino.cache.enabled = false # isolates from entity-store cache
    gravitino.authorization.enable         = true
    gravitino.authorization.serviceAdmins  = admin
    gravitino.entity.store.relational.jdbcUrl = <shared MySQL>
    \`\`\`
    
    A \`paths-filter\` job keeps the workflow from running for unrelated
    changes.
    
    ### Why are the changes needed?
    
    The script exercises the \`JcasbinAuthorizer\` cache layer end-to-end
    across instances;
    without a CI gate, regressions in these paths only surface when someone
    runs the script
    by hand. The recent \`versionCheckAndLoadRoles\` fix for the role-delete
    propagation gap
    (follow-up to #10996) in particular has no automated coverage.
    
    Fix: #11212
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    The script itself is the test. It passes 128/128 in CI against two
    instances:
    
    \`\`\`
    == Summary ==
    Passed: 128
    Failed: 0
    \`\`\`
    
    The workflow file is structured per the existing access-control /
    integration test
    workflows in this repo.
---
 .../workflows/multi-instance-consistency-test.yml  |  117 +++
 dev/ci/setup_multi_instance.sh                     |  157 +++
 dev/ci/test_multi_instance_consistency.sh          | 1051 ++++++++++++++++++++
 .../TestIcebergCatalogPropertyConverter.java       |    9 +-
 4 files changed, 1329 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/multi-instance-consistency-test.yml 
b/.github/workflows/multi-instance-consistency-test.yml
new file mode 100644
index 0000000000..9b99c11035
--- /dev/null
+++ b/.github/workflows/multi-instance-consistency-test.yml
@@ -0,0 +1,117 @@
+name: Multi-Instance Consistency Test
+
+# Verifies that authorization state (owners, roles, role privileges, user-role
+# grants) propagates correctly between two Gravitino instances backed by the
+# same MySQL entity store. Both instances run with the entity cache disabled
+# and authorization enabled, so the only consistency contracts under test are
+# the JcasbinAuthorizer caches and the JcasbinChangePoller invalidation paths.
+#
+# The test plan and assertions live in 
dev/ci/test_multi_instance_consistency.sh.
+
+on:
+  push:
+    branches: [ "main", "branch-*" ]
+  pull_request:
+    branches: [ "main", "branch-*" ]
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || 
github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  changes:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
+        id: filter
+        with:
+          filters: |
+            authz_changes:
+              - .github/workflows/multi-instance-consistency-test.yml
+              - api/**
+              - common/**
+              - core/**
+              - server/**
+              - server-common/**
+              - conf/**
+              - scripts/mysql/**
+              - dev/ci/test_multi_instance_consistency.sh
+              - dev/ci/setup_multi_instance.sh
+              - build.gradle.kts
+              - gradle.properties
+              - gradle/**
+              - settings.gradle.kts
+    outputs:
+      authz_changes: ${{ steps.filter.outputs.authz_changes }}
+
+  multi-instance-consistency:
+    needs: changes
+    if: needs.changes.outputs.authz_changes == 'true'
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    services:
+      mysql:
+        image: mysql:8.0.33
+        env:
+          MYSQL_ROOT_PASSWORD: gravitino
+          MYSQL_DATABASE: gravitino
+          MYSQL_USER: gravitino
+          MYSQL_PASSWORD: gravitino
+        ports:
+          - 3306:3306
+        options: >-
+          --health-cmd="mysqladmin ping -uroot -pgravitino --silent"
+          --health-interval=5s
+          --health-timeout=3s
+          --health-retries=20
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-java@v4
+        with:
+          java-version: 17
+          distribution: 'temurin'
+          cache: 'gradle'
+
+      - name: Free disk space
+        run: dev/ci/util_free_space.sh
+
+      - name: Apply Gravitino MySQL schema
+        run: |
+          # Keep in sync with the latest schema file under scripts/mysql/ when 
the version bumps.
+          mysql -h 127.0.0.1 -P 3306 -ugravitino -pgravitino gravitino \
+            < scripts/mysql/schema-1.3.0-mysql.sql
+
+      - name: Build Gravitino distribution
+        run: ./gradlew compileDistribution -PskipWeb=true -x test
+
+      - name: Stage and start two instances (A=8090, B=8190)
+        run: bash dev/ci/setup_multi_instance.sh
+
+      - name: Run multi-instance consistency tests
+        id: consistency
+        env:
+          INSTANCE_A: http://localhost:8090
+          INSTANCE_B: http://localhost:8190
+          ADMIN_USER: admin
+          POLL_WAIT_SECS: 6
+        run: bash dev/ci/test_multi_instance_consistency.sh
+
+      - name: Stop Gravitino servers
+        if: always()
+        run: |
+          distribution/package/bin/gravitino.sh   stop || true
+          distribution/package-b/bin/gravitino.sh stop || true
+
+      - name: Upload logs on failure
+        if: ${{ failure() || cancelled() }}
+        uses: actions/upload-artifact@v7
+        with:
+          name: multi-instance-consistency-logs
+          path: |
+            distribution/package/logs/
+            distribution/package-b/logs/
+            distribution/package/conf/gravitino.conf
+            distribution/package-b/conf/gravitino.conf
diff --git a/dev/ci/setup_multi_instance.sh b/dev/ci/setup_multi_instance.sh
new file mode 100755
index 0000000000..8482d41def
--- /dev/null
+++ b/dev/ci/setup_multi_instance.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Stages two Gravitino distribution instances (A on port 8090, B on 8190)
+# backed by a shared MySQL entity store and starts both servers.
+# Called by .github/workflows/multi-instance-consistency-test.yml.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+cd "${REPO_ROOT}"
+
+# ---------------------------------------------------------------------------
+# 1. MySQL JDBC driver
+#    The entity store needs the connector in libs/, not only under
+#    catalogs/jdbc-mysql/libs/ (which is for the JDBC catalog backend).
+#    mysql-connector-java was renamed to mysql-connector-j starting with
+#    8.0.31 (new Maven group: com.mysql).
+# ---------------------------------------------------------------------------
+JDBC_VERSION=8.0.33
+JDBC_JAR=distribution/package/libs/mysql-connector-j-${JDBC_VERSION}.jar
+JDBC_URL=https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/${JDBC_VERSION}/mysql-connector-j-${JDBC_VERSION}.jar
+
+curl -fsSL "${JDBC_URL}" -o "${JDBC_JAR}"
+EXPECTED_SHA1="$(curl -fsSL "${JDBC_URL}.sha1")"
+echo "${EXPECTED_SHA1}  ${JDBC_JAR}" | sha1sum --check
+
+# ---------------------------------------------------------------------------
+# 2. Copy distribution to get two independent instances.
+# ---------------------------------------------------------------------------
+cp -a distribution/package distribution/package-b
+
+# ---------------------------------------------------------------------------
+# 3. Patch gravitino.conf for each instance.
+#    Uses Python for reliable key-present/key-absent handling: if a key
+#    already exists it is replaced in-place; if it is absent it is appended
+#    so it is never silently skipped.
+# ---------------------------------------------------------------------------
+configure_instance() {
+  local conf="$1" http="$2" iceberg="$3" lance="$4"
+  python3 - "$conf" "$http" "$iceberg" "$lance" <<'PY'
+import sys, re, pathlib
+conf, http, iceberg, lance = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
+p = pathlib.Path(conf)
+src = p.read_text()
+subs = [
+  (r'^gravitino\.entity\.store\.relational\.jdbcUrl\s*=.*$',
+   'gravitino.entity.store.relational.jdbcUrl = '
+   
'jdbc:mysql://127.0.0.1:3306/gravitino?useSSL=false&allowPublicKeyRetrieval=true'),
+  (r'^gravitino\.entity\.store\.relational\.jdbcDriver\s*=.*$',
+   'gravitino.entity.store.relational.jdbcDriver = com.mysql.cj.jdbc.Driver'),
+  (r'^gravitino\.entity\.store\.relational\.jdbcUser\s*=.*$',
+   'gravitino.entity.store.relational.jdbcUser = gravitino'),
+  (r'^gravitino\.entity\.store\.relational\.jdbcPassword\s*=.*$',
+   'gravitino.entity.store.relational.jdbcPassword = gravitino'),
+  (r'^gravitino\.cache\.enabled\s*=.*$',
+   'gravitino.cache.enabled = false'),
+  (r'^gravitino\.authorization\.enable\s*=.*$',
+   'gravitino.authorization.enable = true'),
+  (r'^gravitino\.authorization\.serviceAdmins\s*=.*$',
+   'gravitino.authorization.serviceAdmins = admin'),
+  (r'^gravitino\.server\.webserver\.httpPort\s*=.*$',
+   f'gravitino.server.webserver.httpPort = {http}'),
+  (r'^gravitino\.iceberg-rest\.httpPort\s*=.*$',
+   f'gravitino.iceberg-rest.httpPort = {iceberg}'),
+  (r'^gravitino\.lance-rest\.httpPort\s*=.*$',
+   f'gravitino.lance-rest.httpPort = {lance}'),
+]
+appended = []
+for pat, repl in subs:
+  new_src = re.sub(pat, repl, src, flags=re.MULTILINE)
+  if new_src == src:
+    appended.append(repl)
+  src = new_src
+if appended:
+  src = src.rstrip('\n') + '\n' + '\n'.join(appended) + '\n'
+p.write_text(src)
+PY
+}
+
+configure_instance distribution/package/conf/gravitino.conf   8090 9001 9101
+configure_instance distribution/package-b/conf/gravitino.conf 8190 9011 9111
+
+# Instance B also needs the JDBC driver.
+cp "${JDBC_JAR}" distribution/package-b/libs/
+
+# ---------------------------------------------------------------------------
+# 4. Patch bin/gravitino.sh PID detection so two installs on the same host
+#    do not interfere.
+#    bin/gravitino.sh's found_gravitino_server_pid() greps `ps` only by
+#    the "GravitinoServer" process name. When two installs share a host the
+#    second `start` invocation sees the first install's JVM and
+#    short-circuits with "Gravitino Server is already running" — B never
+#    actually starts. Scope the grep by GRAVITINO_HOME so each install only
+#    matches its own JVM.
+# ---------------------------------------------------------------------------
+patch_pid_grep() {
+  python3 - "$1" <<'PY'
+import sys, pathlib
+p = pathlib.Path(sys.argv[1])
+src = p.read_text()
+# If the script already scopes the PID lookup by GRAVITINO_HOME
+# (the template was updated upstream), no patching is needed.
+if '${GRAVITINO_HOME}' in src:
+  sys.exit(0)
+old = ("RUNNING_PIDS=$(ps x | grep ${process_name} | "
+       "grep -v grep | awk '{print $1}');")
+new = ("RUNNING_PIDS=$(ps x | grep ${process_name} | "
+       'grep "${GRAVITINO_HOME}" | '
+       "grep -v grep | awk '{print $1}');")
+assert old in src, f"pid-grep line not found in {p}"
+p.write_text(src.replace(old, new))
+PY
+}
+
+patch_pid_grep distribution/package/bin/gravitino.sh
+patch_pid_grep distribution/package-b/bin/gravitino.sh
+
+# ---------------------------------------------------------------------------
+# 5. Start both instances and wait until they are ready.
+# ---------------------------------------------------------------------------
+distribution/package/bin/gravitino.sh   start
+distribution/package-b/bin/gravitino.sh start
+
+wait_ready() {
+  local url=$1
+  for i in $(seq 1 60); do
+    if curl -fsS --connect-timeout 2 --max-time 5 "$url/api/version" 
>/dev/null 2>&1; then
+      echo "$url ready"
+      return 0
+    fi
+    sleep 2
+  done
+  echo "$url did NOT become ready in time" >&2
+  cat distribution/package/logs/*.log   2>/dev/null || true
+  cat distribution/package-b/logs/*.log 2>/dev/null || true
+  return 1
+}
+
+wait_ready http://localhost:8090
+wait_ready http://localhost:8190
diff --git a/dev/ci/test_multi_instance_consistency.sh 
b/dev/ci/test_multi_instance_consistency.sh
new file mode 100755
index 0000000000..bc670d8ed3
--- /dev/null
+++ b/dev/ci/test_multi_instance_consistency.sh
@@ -0,0 +1,1051 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+#
+# Multi-instance consistency test for the JcasbinAuthorizer cache propagation.
+#
+# What this verifies (one cluster, two Gravitino instances backed by the same 
DB):
+#
+#   A. metadataIdCache / ownerRelCache are eventually consistent across 
instances
+#      via the JcasbinChangePoller (default poll interval = 3s).
+#      → set owner on A, read it back on B.
+#
+#   B. The new owner can actually exercise owner privileges on the *other* 
instance
+#      after the poll cycle, and the old owner is rejected.
+#      → A sets owner=alice; on B alice can re-setOwner, jack gets 403.
+#
+#   C. userRoleCache (per-request, version-validated) reflects role grants
+#      *immediately* on the other instance — no poll wait needed.
+#      → grant role on A; on B a user with no prior privileges immediately
+#        succeeds at an action gated by that role.
+#
+#   D. Same path, reverse direction: revoke role on A is immediately effective 
on B.
+#
+#   E. loadedRoles cache (per-request, version-validated) reflects 
role-privilege
+#      changes immediately on the other instance.
+#      → revoke MANAGE_USERS from role on A; user is denied on B even though
+#        the role binding itself didn't change.
+#      → grant CREATE_ROLE on the same role on A; user can immediately use it 
on B.
+#
+#   S. ownerRelCache handles GROUP-type owners, not just USER-type.
+#      → setOwner(TAG, type=GROUP) on A; B reads back the GROUP owner after 
poll.
+#
+#   T. metadataIdCache is invalidated when an entity is deleted and re-created
+#      under the same name (new entity ID in the DB).
+#      → set owner of tag T to bob; delete T on A; re-create T on A (new ID,
+#        owner = alice); B must NOT return bob as the owner of the new T.
+#
+#   U. User role-list metadata read-back (not just enforcement).
+#      → grant/revoke a role on A; GET /users/{user} on B immediately shows the
+#        updated role list (userRoleCache is version-validated per request).
+#
+#   V. Role privilege metadata read-back (not just enforcement).
+#      → swap privileges on a role on A; GET /roles/{role} on B shows the new
+#        set and no longer shows the removed privilege.
+#
+#   W. Bidirectional propagation: changes written to B are immediately visible
+#      on A (same per-request version-validation, reverse direction).
+#      → grant on B; addUser on A succeeds immediately; revoke on B; denied on 
A.
+#
+# All authz-enforcement assertions exercise metalake-internal APIs (addUser,
+# createRole, setOwner) so the test runs without any external catalog backend.
+# The propagation guarantees being tested apply identically to schemas, tables,
+# filesets, etc. — only the path you'd PUT/GET differs.
+#
+# -----------------------------------------------------------------------------
+# Server preconditions (configure both instances BEFORE running):
+#   gravitino.authorization.enable                          = true
+#   gravitino.authenticators                                = simple
+#   gravitino.authorization.serviceAdmins                   = admin   # 
required for metalake create
+#   gravitino.entity-store                                  = relational
+#   gravitino.entity-store.relational.jdbcUrl               = <shared 
MySQL/PostgreSQL>
+#   gravitino.authorization.jcasbin.changePollIntervalSecs  = 3   (default; 
lower = faster A↔B)
+#
+# H2 is NOT supported here — both instances must share one real SQL backend so
+# entity_change_log is visible to both pollers.
+#
+# Note on the "admin" role: per JcasbinAuthorizer.hasSetOwnerPermission, 
service-admin
+# does NOT bypass ownership checks — only the current owner can setOwner. The 
script
+# therefore does ownership chains as the *current* owner (admin → jack → alice)
+# and uses a separate user (bob) for role-based tests so role grants and
+# ownership grants don't interfere.
+# -----------------------------------------------------------------------------
+#
+# Usage:
+#   bash dev/ci/test_multi_instance_consistency.sh
+#   INSTANCE_A=http://host1:8090 INSTANCE_B=http://host2:8090 bash 
dev/ci/test_multi_instance_consistency.sh
+#
+# Exit code = number of failed assertions (0 = all pass).
+
+set -uo pipefail
+
+# ---- config ----------------------------------------------------------------
+
+INSTANCE_A="${INSTANCE_A:-http://localhost:8090}";
+INSTANCE_B="${INSTANCE_B:-http://localhost:8190}";
+ADMIN_USER="${ADMIN_USER:-admin}"
+POLL_WAIT_SECS="${POLL_WAIT_SECS:-6}"   # default poll = 3s + buffer
+
+SUFFIX="$(date +%s)_$$"
+METALAKE="cons_test_${SUFFIX}"
+METALAKE2="cons_test2_${SUFFIX}"  # for cross-metalake isolation check (Phase 
J)
+USER_JACK="jack_${SUFFIX}"
+USER_ALICE="alice_${SUFFIX}"
+USER_BOB="bob_${SUFFIX}"   # separate test user, never an owner
+USER_DELME="userdel_${SUFFIX}"   # for delete-user / re-create tests (Phases 
G, H)
+ROLE_NAME="role_${SUFFIX}"
+ROLE_FRESH="role_g_${SUFFIX}"     # for Phase G (delete user test)
+ROLE_ALLOW="role_allow_${SUFFIX}" # for Phase K (DENY override)
+ROLE_DENY="role_deny_${SUFFIX}"   # for Phase K
+GROUP_NAME="group_${SUFFIX}"      # for Phase L
+TAG_NAME="tag_${SUFFIX}"          # for Phase N
+GROUP_S="grp_s_${SUFFIX}"         # for Phase S (GROUP-type owner)
+TAG_S="tag_s_${SUFFIX}"           # for Phase S
+TAG_T="tag_t_${SUFFIX}"           # for Phase T (metadataIdCache re-create)
+ROLE_U="role_u_${SUFFIX}"         # for Phase U (user role read-back)
+ROLE_V="role_v_${SUFFIX}"         # for Phase V (role privilege read-back)
+ROLE_W="role_w_${SUFFIX}"         # for Phase W (bidirectional B→A)
+
+PASS=0
+FAIL=0
+FAILED_TESTS=()
+
+# ---- helpers ---------------------------------------------------------------
+
+# Simple authenticator wants: Authorization: Basic base64(user:)
+auth_header() { printf 'Basic %s' "$(printf '%s:' "$1" | base64 | tr -d 
'\n')"; }
+
+# api <base_url> <as_user> <method> <path> [body_json]
+# Writes status code to HTTP_CODE, body to RESPONSE_BODY.
+api() {
+  local base="$1" user="$2" method="$3" path="$4" body="${5:-}"
+  local tmp
+  tmp="$(mktemp -t graviton_api.XXXX)"
+  if [[ -n "$body" ]]; then
+    HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 30 \
+      -o "$tmp" -w '%{http_code}' \
+      -H "Authorization: $(auth_header "$user")" \
+      -H 'Content-Type: application/json' \
+      -X "$method" --data "$body" "${base}${path}" || echo 000)
+  else
+    HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 30 \
+      -o "$tmp" -w '%{http_code}' \
+      -H "Authorization: $(auth_header "$user")" \
+      -X "$method" "${base}${path}" || echo 000)
+  fi
+  RESPONSE_BODY="$(cat "$tmp")"
+  rm -f "$tmp"
+}
+
+pass() { PASS=$((PASS+1)); printf '  \033[32m✓\033[0m %s\n' "$1"; }
+fail() {
+  FAIL=$((FAIL+1)); FAILED_TESTS+=("$1")
+  printf '  \033[31m✗\033[0m %s\n' "$1"
+}
+
+# expect <desc> <expected_http_code> [body_grep_pattern]
+expect() {
+  local desc="$1" want="$2" pattern="${3:-}"
+  local body_snip
+  body_snip=$(printf '%s' "$RESPONSE_BODY" | tr '\n' ' ' | cut -c1-180)
+  if [[ "$HTTP_CODE" != "$want" ]]; then
+    fail "$desc — expected HTTP $want, got $HTTP_CODE. Body: $body_snip"
+    return
+  fi
+  if [[ -n "$pattern" ]] && ! printf '%s' "$RESPONSE_BODY" | grep -q -E 
"$pattern"; then
+    fail "$desc — HTTP $want OK but body missing /$pattern/. Body: $body_snip"
+    return
+  fi
+  pass "$desc"
+}
+
+section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
+
+# expect_absent <desc> <expected_http_code> <body_grep_pattern>
+# Passes when the HTTP code matches AND the pattern is NOT found in the body.
+expect_absent() {
+  local desc="$1" want="$2" pattern="$3"
+  local body_snip
+  body_snip=$(printf '%s' "$RESPONSE_BODY" | tr '\n' ' ' | cut -c1-180)
+  if [[ "$HTTP_CODE" != "$want" ]]; then
+    fail "$desc — expected HTTP $want, got $HTTP_CODE. Body: $body_snip"
+    return
+  fi
+  if printf '%s' "$RESPONSE_BODY" | grep -q -E "$pattern"; then
+    fail "$desc — HTTP $want OK but body unexpectedly matches /$pattern/. 
Body: $body_snip"
+    return
+  fi
+  pass "$desc"
+}
+
+# ---- preflight -------------------------------------------------------------
+
+section "0. Preflight"
+api "$INSTANCE_A" "$ADMIN_USER" GET '/api/version'
+expect "instance A reachable + admin auth works" 200 'gitCommit'
+api "$INSTANCE_B" "$ADMIN_USER" GET '/api/version'
+expect "instance B reachable + admin auth works" 200 'gitCommit'
+
+# Confirm 'admin' is configured as a service admin on both instances — 
required to
+# create metalakes (the only service-admin-gated step we need).
+SCRATCH="scratch_${SUFFIX}"
+api "$INSTANCE_A" "$ADMIN_USER" POST '/api/metalakes' \
+  "{\"name\":\"$SCRATCH\",\"comment\":\"scratch\",\"properties\":{}}"
+if [[ "$HTTP_CODE" == "200" ]]; then
+  pass "admin is a service admin on A (can create metalake)"
+  api "$INSTANCE_A" "$ADMIN_USER" DELETE "/api/metalakes/$SCRATCH?force=true"
+elif [[ "$HTTP_CODE" == "403" ]]; then
+  fail "admin lacks service-admin rights on A — add '$ADMIN_USER' to 
gravitino.authorization.serviceAdmins on both instances and restart"
+else
+  fail "preflight metalake-create returned unexpected HTTP $HTTP_CODE 
(expected 200 or 403) — instance A may not be ready or the entity store is not 
configured"
+fi
+
+if [[ $FAIL -gt 0 ]]; then
+  echo
+  echo "Preflight failed — aborting. Fix the above before re-running."
+  exit 99
+fi
+
+# ---- cleanup hook ----------------------------------------------------------
+
+cleanup_metalake() {
+  local ml="$1"
+  for who in "$USER_ALICE" "$USER_JACK" "$ADMIN_USER"; do
+    api "$INSTANCE_A" "$who" DELETE "/api/metalakes/$ml?force=true"
+    if [[ "$HTTP_CODE" =~ ^(200|404)$ ]]; then
+      echo "  deleted $ml (as $who, HTTP $HTTP_CODE)"
+      return
+    fi
+  done
+  echo "  WARNING: failed to delete $ml — last HTTP $HTTP_CODE"
+}
+
+cleanup() {
+  section "Cleanup"
+  # Force-delete cascades through users/roles/tags/groups inside the metalake.
+  # The metalake may be owned by any of (admin / jack / alice) depending on
+  # how far the test got. Try each in turn until DELETE returns 200/404.
+  cleanup_metalake "$METALAKE"
+  cleanup_metalake "$METALAKE2"
+}
+trap cleanup EXIT
+
+# ---- setup (on instance A) -------------------------------------------------
+
+section "1. Setup on A"
+api "$INSTANCE_A" "$ADMIN_USER" POST '/api/metalakes' \
+  "{\"name\":\"$METALAKE\",\"comment\":\"consistency test\",\"properties\":{}}"
+expect "create metalake $METALAKE (admin = service admin, becomes initial 
owner)" 200
+
+api "$INSTANCE_A" "$ADMIN_USER" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"$USER_JACK\"}"
+expect "add user $USER_JACK" 200
+
+api "$INSTANCE_A" "$ADMIN_USER" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"$USER_ALICE\"}"
+expect "add user $USER_ALICE" 200
+
+api "$INSTANCE_A" "$ADMIN_USER" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"$USER_BOB\"}"
+expect "add user $USER_BOB (used for role tests, never an owner)" 200
+
+# Role with MANAGE_USERS at metalake scope — holder can addUser / removeUser.
+api "$INSTANCE_A" "$ADMIN_USER" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{
+  "name": "$ROLE_NAME",
+  "properties": {},
+  "securableObjects": [
+    {
+      "fullName": "$METALAKE",
+      "type": "METALAKE",
+      "privileges": [{"name": "MANAGE_USERS", "condition": "ALLOW"}]
+    }
+  ]
+}
+EOF
+)"
+expect "create role $ROLE_NAME with MANAGE_USERS on metalake" 200
+
+# ---- A. owner read-back propagates (eventual ~3s) --------------------------
+
+section "A. Owner read-back propagates from A → B (eventual, 
~${POLL_WAIT_SECS}s)"
+# admin is current owner → admin transfers to jack
+api "$INSTANCE_A" "$ADMIN_USER" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "A: admin (owner) → setOwner = $USER_JACK" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for the change poller on B"
+sleep "$POLL_WAIT_SECS"
+
+api "$INSTANCE_B" "$ADMIN_USER" GET 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE"
+expect "B: GET owner now reports $USER_JACK" 200 "\"name\"[^}]*\"$USER_JACK\""
+
+# jack is now owner → jack transfers to alice
+api "$INSTANCE_A" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "A: $USER_JACK (owner) → setOwner = $USER_ALICE" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for the change poller on B"
+sleep "$POLL_WAIT_SECS"
+
+api "$INSTANCE_B" "$ADMIN_USER" GET 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE"
+expect "B: GET owner now reports $USER_ALICE" 200 
"\"name\"[^}]*\"$USER_ALICE\""
+
+# ---- B. enforcement on B reflects the owner change -------------------------
+
+section "B. Authz enforcement on B reflects the new owner (no extra wait)"
+# alice is the current owner — re-setting (alice → alice) is a no-op write 
that requires
+# the caller to be the current owner. If B's ownerRelCache is invalidated, 
this works.
+# Note: this is also B's *first* isOwner check for $METALAKE, so its 
ownerRelCache
+# entry is being populated cold from the DB here.
+api "$INSTANCE_B" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "B: $USER_ALICE (current owner) re-asserts ownership" 200
+
+# jack was owner two transfers ago but is not anymore.
+api "$INSTANCE_B" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "B: $USER_JACK (former owner) is rejected with 403" 403
+
+# ---- B'. WARM-cache invalidation: critical scenario ------------------------
+#
+# Phases A/B above only exercise *cold* cache fills on B: when B's first
+# isOwner check happens, the new owner is already in the DB, so the cache
+# is correctly populated. They do NOT prove that an already-cached entry on
+# B (with the stale owner) gets invalidated when A changes the owner.
+#
+# This phase forces that scenario:
+#   1. B's ownerRelCache currently holds alice (warmed by Phase B above).
+#   2. Change owner on A back to jack.
+#   3. Wait one poll cycle.
+#   4. On B, do an isOwner-gated operation as jack — must succeed if and
+#      only if the poller invalidated B's alice entry. As alice — must fail.
+#
+section "B'. Warm-cache invalidation on owner change (eventual, 
~${POLL_WAIT_SECS}s)"
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "A: $USER_ALICE (owner) transfers owner back to $USER_JACK" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for poller to invalidate B's warm cache"
+sleep "$POLL_WAIT_SECS"
+
+# B's cache previously held alice. If the poller invalidated correctly, the
+# next isOwner check reloads jack from DB.
+api "$INSTANCE_B" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "B: $USER_JACK (new owner) re-asserts ownership — proves B's cache 
invalidated" 200
+
+api "$INSTANCE_B" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "B: $USER_ALICE (former owner, was cached) now rejected" 403
+
+# Transfer back to alice for the remainder of the test (which assumes alice is 
owner).
+api "$INSTANCE_A" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "A: $USER_JACK restores owner = $USER_ALICE for subsequent phases" 200
+echo "  ...sleeping ${POLL_WAIT_SECS}s so B sees alice-as-owner again before 
role tests"
+sleep "$POLL_WAIT_SECS"
+
+# ---- C. grant role on A → user can act on B IMMEDIATELY --------------------
+
+section "C. Grant role on A — effective on B without poll wait 
(version-validated)"
+# Baseline: bob has no role, no ownership, so addUser is forbidden.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"baseline_denied_${SUFFIX}\"}"
+expect "B: $USER_BOB denied addUser before role grant (baseline)" 403
+
+# As alice (owner): grant the MANAGE_USERS role to bob on A.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_NAME\"]}"
+expect "A: $USER_ALICE grants $ROLE_NAME to $USER_BOB" 200
+
+# No sleep — userRoleCache is version-validated per request.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"granted_ok_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser succeeds immediately after grant" 200
+
+# ---- D. revoke role on A → user denied on B IMMEDIATELY --------------------
+
+section "D. Revoke role on A — effective on B without poll wait"
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_NAME\"]}"
+expect "A: $USER_ALICE revokes $ROLE_NAME from $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"revoked_denied_${SUFFIX}\"}"
+expect "B: $USER_BOB denied addUser immediately after revoke" 403
+
+# ---- E. modify role's privileges on A → effective on B IMMEDIATELY ---------
+
+section "E. Modify role privileges on A — effective on B without poll wait"
+# Re-grant the role to bob so the test isolates a *role-privilege* change
+# rather than a *role-membership* change.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_NAME\"]}"
+expect "A: $USER_ALICE re-grants $ROLE_NAME to $USER_BOB for privilege test" 
200
+
+# Sanity: bob can still addUser at this point.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"priv_baseline_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser works (role re-granted, sanity)" 200
+
+# Strip MANAGE_USERS from the role itself on A.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  
"/api/metalakes/$METALAKE/permissions/roles/$ROLE_NAME/METALAKE/$METALAKE/revoke/"
 \
+  '{"privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}'
+expect "A: $USER_ALICE revokes MANAGE_USERS from $ROLE_NAME" 200
+
+# bob still holds the role, but the role no longer grants MANAGE_USERS.
+# loadedRoles must be invalidated on B for this to fail.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"priv_revoked_${SUFFIX}\"}"
+expect "B: $USER_BOB denied after role's MANAGE_USERS privilege revoked" 403
+
+# Now add CREATE_ROLE to the same role on A.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  
"/api/metalakes/$METALAKE/permissions/roles/$ROLE_NAME/METALAKE/$METALAKE/grant/"
 \
+  '{"privileges":[{"name":"CREATE_ROLE","condition":"ALLOW"}]}'
+expect "A: $USER_ALICE grants CREATE_ROLE to $ROLE_NAME" 200
+
+# bob still can't addUser (role doesn't have MANAGE_USERS anymore)
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"still_no_manage_users_${SUFFIX}\"}"
+expect "B: $USER_BOB still denied addUser (role has only CREATE_ROLE)" 403
+
+# …but he CAN createRole now, immediately.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{
+  "name": "bob_role_${SUFFIX}",
+  "properties": {},
+  "securableObjects": [
+    {
+      "fullName": "$METALAKE",
+      "type": "METALAKE",
+      "privileges": [{"name": "MANAGE_USERS","condition":"ALLOW"}]
+    }
+  ]
+}
+EOF
+)"
+expect "B: $USER_BOB createRole succeeds immediately after CREATE_ROLE granted 
on A" 200
+
+# ============================================================================
+# Phase F-N: extended coverage — lifecycle operations, isolation, DENY, groups,
+# non-METALAKE setOwner.
+# State at this point: $METALAKE owner = $USER_ALICE; $USER_BOB holds
+# $ROLE_NAME which currently has only CREATE_ROLE (MANAGE_USERS revoked in E).
+# ============================================================================
+
+# ---- F. delete role on A → users with that role lose its privileges --------
+
+section "F. Delete role on A — holders lose its privileges on B (immediate)"
+# versionCheckAndLoadRoles compares the caller's cached roleIds against the
+# rows returned by batchGetRoleUpdatedAt; any roleId absent from the DB
+# (i.e., the deleted role) is evicted from loadedRoles. The per-request
+# userRoleCache is also revalidated on the next request so the deleted
+# role's policies are never applied.
+
+# Sanity: bob currently holds ROLE_NAME which has CREATE_ROLE.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"sanity_role_${SUFFIX}","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "B: $USER_BOB createRole still works (sanity)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE 
"/api/metalakes/$METALAKE/roles/$ROLE_NAME"
+expect "A: $USER_ALICE deletes role $ROLE_NAME" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"post_delete_${SUFFIX}","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "B: $USER_BOB createRole now denied (role deleted on A)" 403
+
+# ---- G. delete user (caller) on A → that user's requests rejected on B -----
+
+section "G. Delete user on A — calls from that user are rejected on B 
immediately"
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"$USER_DELME\"}"
+expect "A: create user $USER_DELME" 200
+
+# Create a fresh role and grant MANAGE_USERS to the new user.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_FRESH","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_FRESH (MANAGE_USERS)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_DELME/grant/" \
+  "{\"roleNames\":[\"$ROLE_FRESH\"]}"
+expect "A: grant $ROLE_FRESH to $USER_DELME" 200
+
+api "$INSTANCE_B" "$USER_DELME" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"delme_baseline_${SUFFIX}\"}"
+expect "B: $USER_DELME addUser works before deletion (sanity)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE 
"/api/metalakes/$METALAKE/users/$USER_DELME"
+expect "A: delete user $USER_DELME" 200
+
+api "$INSTANCE_B" "$USER_DELME" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"delme_after_${SUFFIX}\"}"
+expect "B: $USER_DELME addUser denied immediately after user deletion" 403
+
+# ---- H. re-create same-named user → no stale role bindings -----------------
+
+section "H. Re-create same-named user — no stale role inheritance"
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"$USER_DELME\"}"
+expect "A: re-create user $USER_DELME (no roles granted this time)" 200
+
+api "$INSTANCE_B" "$USER_DELME" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"new_delme_${SUFFIX}\"}"
+expect "B: re-created $USER_DELME still denied addUser (no stale role leaked 
from old user)" 403
+
+# ---- I. re-create same-named role → fresh privileges only ------------------
+
+section "I. Re-create same-named role — only fresh privileges apply"
+# ROLE_NAME was deleted in F. Re-create it with CREATE_TAG instead of 
MANAGE_USERS.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_NAME","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"CREATE_TAG","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: re-create $ROLE_NAME with only CREATE_TAG" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_NAME\"]}"
+expect "A: grant the new $ROLE_NAME to $USER_BOB" 200
+
+# Fresh privilege works.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"i_tag_${SUFFIX}\",\"comment\":\"phase I 
tag\",\"properties\":{}}"
+expect "B: $USER_BOB createTag works (fresh CREATE_TAG via re-created role)" 
200
+
+# Stale privileges from previous incarnations (MANAGE_USERS, CREATE_ROLE) do 
NOT leak.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"stale_check_${SUFFIX}\"}"
+expect "B: $USER_BOB denied addUser (no stale MANAGE_USERS from old role)" 403
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"stale_role_check_${SUFFIX}","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "B: $USER_BOB denied createRole (no stale CREATE_ROLE from old role)" 
403
+
+# ---- J. cross-metalake isolation -------------------------------------------
+
+section "J. Cross-metalake isolation — role/owner in M1 doesn't grant access 
in M2"
+api "$INSTANCE_A" "$ADMIN_USER" POST '/api/metalakes' \
+  "{\"name\":\"$METALAKE2\",\"comment\":\"isolation test\",\"properties\":{}}"
+expect "A: create second metalake $METALAKE2" 200
+
+api "$INSTANCE_A" "$ADMIN_USER" POST "/api/metalakes/$METALAKE2/users" \
+  "{\"name\":\"$USER_BOB\"}"
+expect "A: add $USER_BOB to $METALAKE2 (with no roles)" 200
+
+# bob has CREATE_TAG in M1 (via $ROLE_NAME), but should have nothing in M2.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE2/tags" \
+  "{\"name\":\"isolation_tag_${SUFFIX}\",\"comment\":\"\",\"properties\":{}}"
+expect "B: $USER_BOB denied createTag in $METALAKE2 (M1 role doesn't apply)" 
403
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE2/users" \
+  "{\"name\":\"isolation_user_${SUFFIX}\"}"
+expect "B: $USER_BOB denied addUser in $METALAKE2" 403
+
+# ---- K. DENY condition overrides ALLOW -------------------------------------
+
+section "K. DENY condition overrides ALLOW on a different role (immediate)"
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_ALLOW","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_ALLOW (MANAGE_USERS ALLOW)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_ALLOW\"]}"
+expect "A: grant $ROLE_ALLOW to $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"allow_only_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser works (only ALLOW role granted)" 200
+
+# Now add a DENY role for the same privilege — DENY should override.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_DENY","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"DENY"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_DENY (MANAGE_USERS DENY)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_DENY\"]}"
+expect "A: grant $ROLE_DENY to $USER_BOB (now has ALLOW + DENY)" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"deny_wins_${SUFFIX}\"}"
+expect "B: $USER_BOB denied addUser (DENY overrides ALLOW)" 403
+
+# Remove DENY — ALLOW should resume working.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_DENY\"]}"
+expect "A: revoke $ROLE_DENY from $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"deny_removed_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser works again after DENY revoked" 200
+
+# ---- L. group lifecycle (metadata-layer read-back) -------------------------
+
+section "L. Group lifecycle — create/grant/delete visible on B"
+# NOTE: simple authenticator does NOT carry group membership; we only verify
+# that group-role bindings are visible across instances at the metadata layer.
+# Runtime enforcement of group-derived privileges needs an IdP that pushes
+# UserPrincipal.getGroups(), which is out of scope here.
+
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/groups" \
+  "{\"name\":\"$GROUP_NAME\"}"
+expect "A: create group $GROUP_NAME" 200
+
+api "$INSTANCE_B" "$ADMIN_USER" GET 
"/api/metalakes/$METALAKE/groups/$GROUP_NAME"
+expect "B: GET group $GROUP_NAME visible immediately" 200 
"\"name\"[^}]*\"$GROUP_NAME\""
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/groups/$GROUP_NAME/grant/" \
+  "{\"roleNames\":[\"$ROLE_ALLOW\"]}"
+expect "A: grant $ROLE_ALLOW to group $GROUP_NAME" 200
+
+api "$INSTANCE_B" "$ADMIN_USER" GET 
"/api/metalakes/$METALAKE/groups/$GROUP_NAME"
+expect "B: GET group shows $ROLE_ALLOW in roles" 200 
"\"roles\"[^]]*\"$ROLE_ALLOW\""
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/groups/$GROUP_NAME/revoke" \
+  "{\"roleNames\":[\"$ROLE_ALLOW\"]}"
+expect "A: revoke $ROLE_ALLOW from group $GROUP_NAME" 200
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE 
"/api/metalakes/$METALAKE/groups/$GROUP_NAME"
+expect "A: delete group $GROUP_NAME" 200
+
+api "$INSTANCE_B" "$ADMIN_USER" GET 
"/api/metalakes/$METALAKE/groups/$GROUP_NAME"
+expect "B: GET group returns 404 after deletion" 404
+
+# ---- N. setOwner propagation for non-METALAKE entities (TAG) ---------------
+
+section "N. setOwner on TAG — same propagation guarantee as METALAKE 
(eventual, ~${POLL_WAIT_SECS}s)"
+# The schema/table case in the original ask works identically — substitute 
SCHEMA/TABLE
+# for TAG once a catalog is configured.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"$TAG_NAME\",\"comment\":\"phase N tag\",\"properties\":{}}"
+expect "A: $USER_ALICE creates tag $TAG_NAME (alice = metalake owner)" 200
+
+# Transfer tag ownership to bob on A. alice is metalake owner so this is 
allowed.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/owners/TAG/$TAG_NAME" \
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "A: setOwner(TAG $TAG_NAME) = $USER_BOB" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for the change poller"
+sleep "$POLL_WAIT_SECS"
+
+# GET owner on a TAG requires LOAD_TAG (METALAKE::OWNER || TAG::OWNER || 
ANY_APPLY_TAG),
+# so the GET is done as $USER_ALICE (metalake owner) rather than admin.
+api "$INSTANCE_B" "$USER_ALICE" GET 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_NAME"
+expect "B: GET owner(TAG $TAG_NAME) = $USER_BOB" 200 
"\"name\"[^}]*\"$USER_BOB\""
+
+# Enforcement: bob (now TAG owner) can DELETE the tag on B (TAG::OWNER 
required).
+api "$INSTANCE_B" "$USER_BOB" DELETE "/api/metalakes/$METALAKE/tags/$TAG_NAME"
+expect "B: $USER_BOB (tag owner) can DELETE the tag on B" 200
+
+# ---- O. multi-role partial revoke ------------------------------------------
+
+section "O. Multi-role partial revoke — only revoked role's privileges go away"
+# After K, bob holds $ROLE_ALLOW (MANAGE_USERS ALLOW) — and also still holds
+# the re-created $ROLE_NAME from Phase I (which grants CREATE_TAG). For the
+# partial-revoke check to be meaningful, $ROLE_TAG_ONLY must be bob's *only*
+# source of CREATE_TAG; otherwise revoking it leaves the privilege intact via
+# the other role and the test fails for a setup reason, not a propagation bug.
+# Revoke $ROLE_NAME first so bob's pre-test state is just [$ROLE_ALLOW].
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_NAME\"]}"
+expect "A: clean up — revoke $ROLE_NAME from $USER_BOB (isolates CREATE_TAG to 
one role)" 200
+
+ROLE_TAG_ONLY="role_tag_${SUFFIX}"
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_TAG_ONLY","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"CREATE_TAG","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_TAG_ONLY (CREATE_TAG only)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_TAG_ONLY\"]}"
+expect "A: grant $ROLE_TAG_ONLY to $USER_BOB (now holds 2 roles)" 200
+
+# Sanity: both privileges work.
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"o_both_user_${SUFFIX}\"}"
+expect "B: addUser works (MANAGE_USERS from $ROLE_ALLOW)" 200
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"o_both_tag_${SUFFIX}\",\"comment\":\"\",\"properties\":{}}"
+expect "B: createTag works (CREATE_TAG from $ROLE_TAG_ONLY)" 200
+
+# Revoke only the tag role.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_TAG_ONLY\"]}"
+expect "A: revoke $ROLE_TAG_ONLY from $USER_BOB ($ROLE_ALLOW kept)" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"o_after_user_${SUFFIX}\"}"
+expect "B: addUser still works after partial revoke ($ROLE_ALLOW intact)" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"o_after_tag_${SUFFIX}\",\"comment\":\"\",\"properties\":{}}"
+expect "B: createTag denied after $ROLE_TAG_ONLY revoked" 403
+
+# ---- P. owner-per-object: TAG owner change with warm cache on B ------------
+
+section "P. Owner change on TAG with warm cache on B (eventual, 
~${POLL_WAIT_SECS}s)"
+# Mirrors Phase B' but for a non-METALAKE object — confirms ownerRelCache
+# invalidation works per-id, not just for the metalake row.
+TAG_P="tag_p_${SUFFIX}"
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"$TAG_P\",\"comment\":\"phase P\",\"properties\":{}}"
+expect "A: create tag $TAG_P (initial owner = creator $USER_ALICE)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_P" \
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "A: setOwner($TAG_P) = $USER_BOB" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s so poller propagates to B"
+sleep "$POLL_WAIT_SECS"
+
+# Warm B's ownerRelCache for $TAG_P: bob does an owner-gated op on B that
+# resolves $TAG_P's owner = bob into cache.
+api "$INSTANCE_B" "$USER_BOB" PUT "/api/metalakes/$METALAKE/owners/TAG/$TAG_P" 
\
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "B: $USER_BOB re-asserts $TAG_P ownership (warms B's cache)" 200
+
+# Change owner on A. alice can do this as metalake owner.
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_P" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "A: setOwner($TAG_P) = $USER_JACK (was $USER_BOB on B's cache)" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for poller to invalidate B's warm cache"
+sleep "$POLL_WAIT_SECS"
+
+# bob's cached owner-of-T_P is stale. Setting owner should fail without the
+# metalake-owner fallback — but bob is NOT metalake owner, so 403.
+api "$INSTANCE_B" "$USER_BOB" PUT "/api/metalakes/$METALAKE/owners/TAG/$TAG_P" 
\
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "B: $USER_BOB (former tag owner, cached) now rejected on $TAG_P" 403
+
+api "$INSTANCE_B" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_P" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "B: $USER_JACK (new tag owner) accepted on $TAG_P" 200
+
+# Metalake-level owner cache must NOT have been invalidated by the tag change.
+# alice (still metalake owner) should still be able to do metalake-scoped ops.
+api "$INSTANCE_B" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"p_sanity_tag_${SUFFIX}\",\"comment\":\"\",\"properties\":{}}"
+expect "B: metalake owner $USER_ALICE still has METALAKE-scope privileges 
(cache surgical)" 200
+
+# ---- Q. several setOwner calls within one poll window — convergence --------
+
+section "Q. Burst of setOwner calls on A — B converges to the final state"
+# Sequence (no inter-step wait so the poller batches them in one cycle):
+#   alice → bob → jack → alice
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "A: setOwner(M) = bob (intermediate)" 200
+api "$INSTANCE_A" "$USER_BOB" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "A: setOwner(M) = jack (intermediate)" 200
+api "$INSTANCE_A" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "A: setOwner(M) = alice (final)" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for poller to process the burst"
+sleep "$POLL_WAIT_SECS"
+
+# Final state is alice. Confirm no intermediate state leaked.
+api "$INSTANCE_B" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_ALICE\",\"type\":\"USER\"}"
+expect "B: $USER_ALICE (final owner of burst) re-asserts" 200
+
+api "$INSTANCE_B" "$USER_BOB" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "B: $USER_BOB (intermediate owner during burst) rejected" 403
+
+api "$INSTANCE_B" "$USER_JACK" PUT 
"/api/metalakes/$METALAKE/owners/METALAKE/$METALAKE" \
+  "{\"name\":\"$USER_JACK\",\"type\":\"USER\"}"
+expect "B: $USER_JACK (intermediate owner during burst) rejected" 403
+
+# ---- R. idempotent re-grant of an existing role assignment -----------------
+
+section "R. Re-granting an already-held role is idempotent"
+# bob still holds $ROLE_ALLOW from K (we revoked the other role in O).
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_ALLOW\"]}"
+expect "A: re-grant $ROLE_ALLOW to $USER_BOB (already held)" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"r_user_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser still works after idempotent re-grant" 200
+
+# Single revoke should fully strip the privilege (no duplicate g-row leftover).
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_ALLOW\"]}"
+expect "A: revoke $ROLE_ALLOW from $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"r_after_revoke_${SUFFIX}\"}"
+expect "B: $USER_BOB addUser denied after single revoke (no duplicate binding 
leftover)" 403
+
+# ---- S. GROUP-type owner: ownerRelCache with GROUP principal ---------------
+
+section "S. GROUP as owner type — tag owned by a group propagates to B 
(eventual, ~${POLL_WAIT_SECS}s)"
+# ownerRelCache stores both USER and GROUP owners. Only USER-type has been
+# tested in the phases above. This phase confirms GROUP-type entries are also
+# propagated correctly by the JcasbinChangePoller.
+# (Simple authenticator cannot enforce group-derived requests, so we verify the
+# metadata read-back only — the owner is stored and visible on B as a GROUP.)
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/groups" \
+  "{\"name\":\"$GROUP_S\"}"
+expect "A: create group $GROUP_S for owner test" 200
+
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"$TAG_S\",\"comment\":\"phase S\",\"properties\":{}}"
+expect "A: $USER_ALICE creates tag $TAG_S" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_S" \
+  "{\"name\":\"$GROUP_S\",\"type\":\"GROUP\"}"
+expect "A: setOwner(TAG $TAG_S) = GROUP $GROUP_S" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for the change poller"
+sleep "$POLL_WAIT_SECS"
+
+api "$INSTANCE_B" "$USER_ALICE" GET 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_S"
+expect "B: GET owner(TAG $TAG_S) = GROUP $GROUP_S (type GROUP propagated)" 200 
\
+  "\"name\"[^}]*\"$GROUP_S\""
+
+# alice is metalake owner and can delete the tag regardless of its object 
owner.
+api "$INSTANCE_A" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/tags/$TAG_S"
+expect "A: $USER_ALICE (metalake owner) deletes tag $TAG_S" 200
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE 
"/api/metalakes/$METALAKE/groups/$GROUP_S"
+expect "A: delete group $GROUP_S" 200
+
+# ---- T. metadataIdCache invalidation: tag delete + same-name re-create -----
+
+section "T. Stale-owner safety on tag delete+re-create (eventual, 
~${POLL_WAIT_SECS}s)"
+# When a tag is deleted on A and re-created with the same name, B must not
+# allow the old owner (bob) to exercise ownership on the new entity.
+#
+# Two separate paths are exercised:
+#  1. GET /owners (OwnerManager.getOwner) does a live DB lookup — bypasses
+#     the JcasbinAuthorizer caches — so it always returns the new owner 
(alice).
+#  2. Enforcement (isOwner inside hasSetOwnerPermission) reads metadataIdCache
+#     → ownerRelCache. Tags do not write to entity_change_log, so
+#     metadataIdCache[name] may still hold the old entity ID. However, the
+#     owner_meta soft-delete IS propagated via the owner_meta poll path, so
+#     ownerRelCache[old_id] is invalidated → live DB returns no owner for the
+#     deleted entity → bob is correctly denied.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"$TAG_T\",\"comment\":\"phase T\",\"properties\":{}}"
+expect "A: create tag $TAG_T (owner = alice as creator)" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_T" \
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "A: setOwner(TAG $TAG_T) = $USER_BOB" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s so B caches bob as owner of $TAG_T"
+sleep "$POLL_WAIT_SECS"
+
+api "$INSTANCE_B" "$USER_ALICE" GET 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_T"
+expect "B: GET owner(TAG $TAG_T) = $USER_BOB (pre-delete baseline)" 200 \
+  "\"name\"[^}]*\"$USER_BOB\""
+
+# Delete the tag on A — should signal the poller to invalidate B's 
metadataIdCache.
+api "$INSTANCE_A" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/tags/$TAG_T"
+expect "A: $USER_ALICE deletes tag $TAG_T (triggers metadataIdCache 
invalidation)" 200
+
+# Re-create a tag with the same name on A — new entity ID in the DB, owner = 
alice.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/tags" \
+  "{\"name\":\"$TAG_T\",\"comment\":\"phase T re-create\",\"properties\":{}}"
+expect "A: re-create tag $TAG_T (new entity ID, new owner = alice)" 200
+
+echo "  ...sleeping ${POLL_WAIT_SECS}s for poller to propagate 
delete+re-create"
+sleep "$POLL_WAIT_SECS"
+
+# B's ownerRelCache had bob for the OLD tag ID. After invalidation the new
+# tag (different ID) must be resolved fresh from the DB → owner = alice.
+api "$INSTANCE_B" "$USER_ALICE" GET 
"/api/metalakes/$METALAKE/owners/TAG/$TAG_T"
+expect "B: GET owner(re-created TAG $TAG_T) = $USER_ALICE (no stale bob 
owner)" 200 \
+  "\"name\"[^}]*\"$USER_ALICE\""
+
+# Enforcement: bob was owner of the old tag; must be rejected for the new one.
+api "$INSTANCE_B" "$USER_BOB" PUT "/api/metalakes/$METALAKE/owners/TAG/$TAG_T" 
\
+  "{\"name\":\"$USER_BOB\",\"type\":\"USER\"}"
+expect "B: $USER_BOB (old tag owner, stale) rejected for re-created $TAG_T" 403
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/tags/$TAG_T"
+expect "A: delete re-created tag $TAG_T" 200
+
+# ---- U. user role-list metadata read-back on B -----------------------------
+
+section "U. User role-list read-back on B — GET /users/{user} reflects 
grant/revoke from A"
+# Phases C/D verify enforcement (can the user DO X?). This phase verifies the
+# metadata layer: GET /users/{user} on B returns the up-to-date role list.
+# userRoleCache is per-request version-validated so no poll wait is needed.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_U","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_U" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_U\"]}"
+expect "A: grant $ROLE_U to $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_ALICE" GET "/api/metalakes/$METALAKE/users/$USER_BOB"
+expect "B: GET user $USER_BOB — $ROLE_U appears in roles list after grant" 200 
\
+  "\"$ROLE_U\""
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_U\"]}"
+expect "A: revoke $ROLE_U from $USER_BOB" 200
+
+api "$INSTANCE_B" "$USER_ALICE" GET "/api/metalakes/$METALAKE/users/$USER_BOB"
+expect_absent "B: GET user $USER_BOB — $ROLE_U absent from roles list after 
revoke" 200 \
+  "\"$ROLE_U\""
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/roles/$ROLE_U"
+expect "A: delete role $ROLE_U" 200
+
+# ---- V. role privilege metadata read-back on B -----------------------------
+
+section "V. Role privilege read-back on B — GET /roles/{role} reflects 
privilege changes from A"
+# Complements Phase E (enforcement) with a metadata-layer check: after the
+# privilege set on a role changes on A, GET /roles/{role} on B must show the
+# updated list. loadedRoles is per-request version-validated, so this is 
immediate.
+api "$INSTANCE_A" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_V","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "A: create role $ROLE_V with MANAGE_USERS" 200
+
+api "$INSTANCE_B" "$USER_ALICE" GET "/api/metalakes/$METALAKE/roles/$ROLE_V"
+expect "B: GET role $ROLE_V shows manage_users (initial)" 200 '"manage_users"'
+
+# Swap privilege set on A: remove MANAGE_USERS, add CREATE_TAG.
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  
"/api/metalakes/$METALAKE/permissions/roles/$ROLE_V/METALAKE/$METALAKE/revoke/" 
\
+  '{"privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}'
+expect "A: revoke MANAGE_USERS from $ROLE_V" 200
+
+api "$INSTANCE_A" "$USER_ALICE" PUT \
+  
"/api/metalakes/$METALAKE/permissions/roles/$ROLE_V/METALAKE/$METALAKE/grant/" \
+  '{"privileges":[{"name":"CREATE_TAG","condition":"ALLOW"}]}'
+expect "A: grant CREATE_TAG to $ROLE_V" 200
+
+api "$INSTANCE_B" "$USER_ALICE" GET "/api/metalakes/$METALAKE/roles/$ROLE_V"
+expect "B: GET role $ROLE_V now shows create_tag" 200 '"create_tag"'
+expect_absent "B: GET role $ROLE_V no longer shows manage_users" 200 
'"manage_users"'
+
+api "$INSTANCE_A" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/roles/$ROLE_V"
+expect "A: delete role $ROLE_V" 200
+
+# ---- W. bidirectional propagation: change on B observed on A ---------------
+
+section "W. Bidirectional — grant on B is immediately effective on A"
+# All prior phases propagate A→B. This phase confirms the reverse direction:
+# a role grant written to B propagates to A without a poll wait, because the
+# shared MySQL is written directly and A's per-request version check reloads 
it.
+api "$INSTANCE_B" "$USER_ALICE" POST "/api/metalakes/$METALAKE/roles" "$(cat 
<<EOF
+{"name":"$ROLE_W","properties":{},"securableObjects":[
+  {"fullName":"$METALAKE","type":"METALAKE",
+   "privileges":[{"name":"MANAGE_USERS","condition":"ALLOW"}]}]}
+EOF
+)"
+expect "B: $USER_ALICE creates role $ROLE_W on B" 200
+
+api "$INSTANCE_B" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/grant/" \
+  "{\"roleNames\":[\"$ROLE_W\"]}"
+expect "B: $USER_ALICE grants $ROLE_W to $USER_BOB on B" 200
+
+# No poll wait — userRoleCache is version-validated per request.
+api "$INSTANCE_A" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"w_bi_${SUFFIX}\"}"
+expect "A: $USER_BOB addUser succeeds immediately after grant made on B" 200
+
+api "$INSTANCE_B" "$USER_ALICE" PUT \
+  "/api/metalakes/$METALAKE/permissions/users/$USER_BOB/revoke/" \
+  "{\"roleNames\":[\"$ROLE_W\"]}"
+expect "B: revoke $ROLE_W from $USER_BOB on B" 200
+
+api "$INSTANCE_A" "$USER_BOB" POST "/api/metalakes/$METALAKE/users" \
+  "{\"name\":\"w_bi_revoked_${SUFFIX}\"}"
+expect "A: $USER_BOB denied addUser immediately after revoke made on B" 403
+
+api "$INSTANCE_B" "$USER_ALICE" DELETE "/api/metalakes/$METALAKE/roles/$ROLE_W"
+expect "B: delete role $ROLE_W" 200
+
+# ---- summary ---------------------------------------------------------------
+
+section "Summary"
+printf "Passed: %d\n" "$PASS"
+printf "Failed: %d\n" "$FAIL"
+if [[ $FAIL -gt 0 ]]; then
+  printf "\nFailed assertions:\n"
+  for t in "${FAILED_TESTS[@]}"; do printf '  - %s\n' "$t"; done
+  exit "$FAIL"
+fi
+echo "All consistency assertions passed."
diff --git 
a/trino-connector/trino-connector/src/test/java/org/apache/gravitino/trino/connector/catalog/iceberg/TestIcebergCatalogPropertyConverter.java
 
b/trino-connector/trino-connector/src/test/java/org/apache/gravitino/trino/connector/catalog/iceberg/TestIcebergCatalogPropertyConverter.java
index 8121280edd..64e6695fa0 100644
--- 
a/trino-connector/trino-connector/src/test/java/org/apache/gravitino/trino/connector/catalog/iceberg/TestIcebergCatalogPropertyConverter.java
+++ 
b/trino-connector/trino-connector/src/test/java/org/apache/gravitino/trino/connector/catalog/iceberg/TestIcebergCatalogPropertyConverter.java
@@ -63,8 +63,8 @@ public class TestIcebergCatalogPropertyConverter {
         ImmutableMap.<String, String>builder()
             .put("uri", 
"jdbc:mysql://127.0.0.1:3306/metastore_db?createDatabaseIfNotExist=true")
             .put("catalog-backend", "jdbc")
-            .put("jdbc-user", "zhangsan")
-            .put("jdbc-password", "lisi")
+            .put("jdbc-user", "jack")
+            .put("jdbc-password", "alice")
             .put("jdbc-driver", "com.mysql.cj.jdbc.Driver")
             .put("other-key", "other")
             .build();
@@ -75,10 +75,9 @@ public class TestIcebergCatalogPropertyConverter {
     Assertions.assertEquals(
         hiveBackendConfig.get("iceberg.jdbc-catalog.connection-url"),
         
"jdbc:mysql://127.0.0.1:3306/metastore_db?createDatabaseIfNotExist=true");
+    
Assertions.assertEquals(hiveBackendConfig.get("iceberg.jdbc-catalog.connection-user"),
 "jack");
     Assertions.assertEquals(
-        hiveBackendConfig.get("iceberg.jdbc-catalog.connection-user"), 
"zhangsan");
-    Assertions.assertEquals(
-        hiveBackendConfig.get("iceberg.jdbc-catalog.connection-password"), 
"lisi");
+        hiveBackendConfig.get("iceberg.jdbc-catalog.connection-password"), 
"alice");
     Assertions.assertNull(hiveBackendConfig.get("other-key"));
     Assertions.assertEquals(hiveBackendConfig.get("iceberg.catalog.type"), 
"jdbc");
     Assertions.assertEquals(

Reply via email to