--- title: "STS Keycloak Files" slug: "sts-keycloak-files" description: "Configure Keycloak clients, manage LDAP users, and create IAM roles with our comprehensive scripts for seamless identity management and security." updated: 2026-10-28T20:02:03Z published: 2026-10-28T20:02:03Z canonical: "docs.vastdata.com/sts-keycloak-files" --- > ## Documentation Index > Fetch the complete documentation index at: https://docs.vastdata.com/llms.txt > Use this file to discover all available pages before exploring further. # STS Keycloak Files ## Scripts #### **configure-keycloak-clients.sh** **configure-keycloak-clients.sh** ```bash #!/bin/bash # configure-keycloak-clients.sh — Phase 2 Keycloak setup (shared across all # three demo variants in sts-keycloak-examples). # # Creates four per-group OIDC clients in the Keycloak `vast` realm so that # the resulting JWT's `aud` claim is load-bearing for VAST role assumption. # Token issuance from each client is gated on Keycloak group membership via: # #     KC group  →  Keycloak realm role  →  conditional-user-role check in #     a per-client direct-grant flow override. # # The script only looks up groups by name. It doesn't care whether those # groups came from LDAP sync or were created natively in Keycloak, which is # why all three variants share this file byte-for-byte. # # Mapping: #   rw-group         → realm role vast-s3-rw → client vast-s3-rw #   ro-group         → realm role vast-s3-ro → client vast-s3-ro #   wo-group         → realm role vast-s3-wo → client vast-s3-wo #   no-access-group  → realm role vast-s3-na → client vast-s3-na # # Idempotent: re-run safely. Each step checks for existing state. # # Required env (with defaults): #   KC_URL    default: http://localhost:8080 #   KC_USER   default: admin #   KC_PASS   (no default — set in env) #   KC_REALM  default: vast set -euo pipefail KC_URL="${KC_URL:-http://localhost:8080}" KC_USER="${KC_USER:-admin}" KC_PASS="${KC_PASS:?set KC_PASS in env (e.g. export KC_PASS=...)}" KC_REALM="${KC_REALM:-vast}" # (kc_group_name, role_name == client_id) ROWS=(    "rw-group:vast-s3-rw"    "ro-group:vast-s3-ro"    "wo-group:vast-s3-wo"    "no-access-group:vast-s3-na" ) #---------------------------------------------------------------------- # Auth + helpers #---------------------------------------------------------------------- echo "==> Authenticating to Keycloak ${KC_URL} as ${KC_USER}" TOKEN=$(curl -sf -X POST "${KC_URL}/realms/master/protocol/openid-connect/token" \    -d "username=${KC_USER}" -d "password=${KC_PASS}" \    -d "grant_type=password" -d "client_id=admin-cli" \    | jq -r '.access_token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then    echo "ERROR: keycloak admin auth failed" >&2    exit 1 fi echo "    OK" api() {    curl -sk -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" "$@" } api_write() {    local method=$1 url=$2 body=$3    local tmp; tmp=$(mktemp)    local code    code=$(curl -sk -o "$tmp" -w "%{http_code}" \        -X "$method" "$url" \        -H "Authorization: Bearer ${TOKEN}" \        -H "Content-Type: application/json" \        -d "$body")    if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then        echo "ERROR: ${method} ${url} returned HTTP ${code}" >&2        echo "  request body: $body" >&2        echo "  response body:" >&2        cat "$tmp" >&2; echo >&2        rm -f "$tmp"; return 1    fi    cat "$tmp"; rm -f "$tmp" } # URL-encode an alias for path use url_encode() {    python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" } #---------------------------------------------------------------------- # 1. Resolve LDAP-synced KC group ids #---------------------------------------------------------------------- echo "==> Resolving Keycloak group ids" declare -A GROUP_IDS for row in "${ROWS[@]}"; do    IFS=':' read -r gname _ <<<"$row"    gid=$(api "${KC_URL}/admin/realms/${KC_REALM}/groups?search=${gname}&exact=true" \        | jq -r --arg n "$gname" 'map(select(.name==$n)) | .[0].id // empty')    [ -z "$gid" ] && { echo "ERROR: KC group '${gname}' not found"; exit 1; }    GROUP_IDS[$gname]=$gid    echo "    ${gname} -> ${gid}" done #---------------------------------------------------------------------- # 2. Realm roles + group→role mappings #---------------------------------------------------------------------- echo "==> Realm roles and group mappings" for row in "${ROWS[@]}"; do    IFS=':' read -r gname role_name <<<"$row"    role_check=$(curl -sk -o /dev/null -w "%{http_code}" \        -H "Authorization: Bearer ${TOKEN}" \        "${KC_URL}/admin/realms/${KC_REALM}/roles/${role_name}")    if [ "$role_check" = "404" ]; then        echo "    [role] '${role_name}' missing, POST"        body=$(jq -n --arg n "$role_name" \            '{name:$n, description:("LDAP group gate for "+$n)}')        api_write POST "${KC_URL}/admin/realms/${KC_REALM}/roles" "$body" >/dev/null    else        echo "    [role] '${role_name}' exists"    fi    role_obj=$(api "${KC_URL}/admin/realms/${KC_REALM}/roles/${role_name}")    gid=${GROUP_IDS[$gname]}    have_mapping=$(api "${KC_URL}/admin/realms/${KC_REALM}/groups/${gid}/role-mappings/realm" \        | jq --arg n "$role_name" 'map(select(.name==$n)) | length')    if [ "$have_mapping" -eq 0 ]; then        echo "    [map] adding ${role_name} to group ${gname}"        body="[$role_obj]"        api_write POST "${KC_URL}/admin/realms/${KC_REALM}/groups/${gid}/role-mappings/realm" "$body" >/dev/null    else        echo "    [map] ${gname} already has ${role_name}"    fi done #---------------------------------------------------------------------- # 3. Clients #---------------------------------------------------------------------- echo "==> Clients" declare -A CLIENT_IDS for row in "${ROWS[@]}"; do    IFS=':' read -r _ client_id <<<"$row"    existing=$(api "${KC_URL}/admin/realms/${KC_REALM}/clients?clientId=${client_id}" \        | jq -r '.[0].id // empty')    if [ -z "$existing" ]; then        echo "    [client] '${client_id}' missing, POST"        body=$(jq -n --arg c "$client_id" '{            clientId: $c,            enabled: true,            publicClient: true,            directAccessGrantsEnabled: true,            standardFlowEnabled: true,            protocol: "openid-connect"        }')        api_write POST "${KC_URL}/admin/realms/${KC_REALM}/clients" "$body" >/dev/null        existing=$(api "${KC_URL}/admin/realms/${KC_REALM}/clients?clientId=${client_id}" \            | jq -r '.[0].id // empty')    else        echo "    [client] '${client_id}' exists"    fi    CLIENT_IDS[$client_id]=$existing    echo "          internal id=${existing}"    # Add the same 'groups' protocol mapper the base vast-s3 client has, so    # the JWT carries the user's KC groups in its `groups` claim. Not    # load-bearing for role assumption (the trust policy gates on `aud`);    # useful for test-script visibility and any downstream bucket-policy    # work that wants the group list.    have_mapper=$(api "${KC_URL}/admin/realms/${KC_REALM}/clients/${existing}/protocol-mappers/models" \        | jq 'map(select(.name=="groups")) | length')    if [ "$have_mapper" -eq 0 ]; then        echo "    [mapper] adding 'groups' mapper to ${client_id}"        body=$(jq -n '{            name: "groups",            protocol: "openid-connect",            protocolMapper: "oidc-group-membership-mapper",            config: {                "full.path": "false",                "id.token.claim": "true",                "access.token.claim": "true",                "claim.name": "groups",                "userinfo.token.claim": "true"            }        }')        api_write POST "${KC_URL}/admin/realms/${KC_REALM}/clients/${existing}/protocol-mappers/models" "$body" >/dev/null    fi done #---------------------------------------------------------------------- # 4. Per-client direct-grant flow with conditional group check sub-flow #---------------------------------------------------------------------- echo "==> Per-client direct-grant flows" for row in "${ROWS[@]}"; do    IFS=':' read -r _ role_name <<<"$row"    flow_alias="${role_name} direct grant"    flow_alias_enc=$(url_encode "$flow_alias")    sub_alias="${role_name} group check"    sub_alias_enc=$(url_encode "$sub_alias")    # 4a. Copy "direct grant" flow if our copy doesn't exist    have_flow=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows" \        | jq --arg a "$flow_alias" 'map(select(.alias==$a)) | length')    if [ "$have_flow" -eq 0 ]; then        echo "    [flow] '${flow_alias}' missing, COPY"        body=$(jq -n --arg n "$flow_alias" '{newName:$n}')        api_write POST \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/direct%20grant/copy" \            "$body" >/dev/null    else        echo "    [flow] '${flow_alias}' exists"    fi    # 4b. Add the Group Check sub-flow if missing    have_sub=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${flow_alias_enc}/executions" \        | jq --arg a "$sub_alias" 'map(select(.displayName==$a)) | length')    if [ "$have_sub" -eq 0 ]; then        echo "    [sub-flow] '${sub_alias}' missing, POST"        body=$(jq -n --arg a "$sub_alias" '{            alias: $a,            type: "basic-flow",            description: "Deny if user lacks the matching realm role"        }')        api_write POST \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${flow_alias_enc}/executions/flow" \            "$body" >/dev/null        # Set the sub-flow's requirement to CONDITIONAL by PUT-ing the parent flow's        # executions list back with the requirement updated.        sub_exec_full=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${flow_alias_enc}/executions" \            | jq --arg a "$sub_alias" 'map(select(.displayName==$a)) | .[0] | .requirement = "CONDITIONAL"')        api_write PUT \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${flow_alias_enc}/executions" \            "$sub_exec_full" >/dev/null    else        echo "    [sub-flow] '${sub_alias}' exists"    fi    # 4c. Add conditional-user-role execution to sub-flow if missing    have_role_exec=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \        | jq 'map(select(.providerId=="conditional-user-role")) | length')    if [ "$have_role_exec" -eq 0 ]; then        echo "    [exec] adding conditional-user-role to '${sub_alias}'"        body=$(jq -n '{provider:"conditional-user-role"}')        api_write POST \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions/execution" \            "$body" >/dev/null        # Mark REQUIRED        role_exec_full=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \            | jq 'map(select(.providerId=="conditional-user-role")) | .[0] | .requirement = "REQUIRED"')        api_write PUT \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \            "$role_exec_full" >/dev/null        # Configure: condUserRole + negate=true        role_exec_id=$(echo "$role_exec_full" | jq -r '.id')        config_body=$(jq -n --arg r "$role_name" \            '{alias:("require "+$r), config:{condUserRole:$r, negate:"true"}}')        api_write POST \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/executions/${role_exec_id}/config" \            "$config_body" >/dev/null    else        echo "    [exec] conditional-user-role already in '${sub_alias}'"    fi    # 4d. Add deny-access execution to sub-flow if missing    have_deny=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \        | jq 'map(select(.providerId=="deny-access-authenticator")) | length')    if [ "$have_deny" -eq 0 ]; then        echo "    [exec] adding deny-access to '${sub_alias}'"        body=$(jq -n '{provider:"deny-access-authenticator"}')        api_write POST \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions/execution" \            "$body" >/dev/null        deny_exec_full=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \            | jq 'map(select(.providerId=="deny-access-authenticator")) | .[0] | .requirement = "REQUIRED"')        api_write PUT \            "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows/${sub_alias_enc}/executions" \            "$deny_exec_full" >/dev/null    else        echo "    [exec] deny-access already in '${sub_alias}'"    fi done #---------------------------------------------------------------------- # 5. Bind each per-client flow as the client's direct_grant override #---------------------------------------------------------------------- echo "==> Binding flows to clients" for row in "${ROWS[@]}"; do    IFS=':' read -r _ client_id <<<"$row"    flow_alias="${client_id} direct grant"    flow_id=$(api "${KC_URL}/admin/realms/${KC_REALM}/authentication/flows" \        | jq -r --arg a "$flow_alias" 'map(select(.alias==$a)) | .[0].id')    cid=${CLIENT_IDS[$client_id]}    cur=$(api "${KC_URL}/admin/realms/${KC_REALM}/clients/${cid}")    cur_bind=$(echo "$cur" | jq -r '.authenticationFlowBindingOverrides.direct_grant // empty')    if [ "$cur_bind" = "$flow_id" ]; then        echo "    [bind] ${client_id} already bound"    else        echo "    [bind] ${client_id} -> ${flow_alias}"        new=$(echo "$cur" | jq --arg fid "$flow_id" \            '.authenticationFlowBindingOverrides = (.authenticationFlowBindingOverrides // {}) + {direct_grant:$fid}')        api_write PUT "${KC_URL}/admin/realms/${KC_REALM}/clients/${cid}" "$new" >/dev/null    fi done #---------------------------------------------------------------------- # Verify #---------------------------------------------------------------------- echo echo "==> Final state" for row in "${ROWS[@]}"; do    IFS=':' read -r _ client_id <<<"$row"    cid=${CLIENT_IDS[$client_id]}    api "${KC_URL}/admin/realms/${KC_REALM}/clients/${cid}" \        | jq '{clientId, directAccessGrantsEnabled, authenticationFlowBindingOverrides}' done echo echo "Done." ``` #### **configure-keycloak-ldap-users-local-groups.sh** **configure-keycloak-ldap-users-local-groups.sh** ```bash #!/bin/bash # configure-keycloak-ldap-users-local-groups.sh — Phase 1 Keycloak setup # for the hybrid variant. # # Users come from LDAP (federated), groups are managed natively in Keycloak. # The classic use case: customer has an existing enterprise LDAP / AD whose # user lifecycle they want to keep using (hires, terminations, password # policies — handled once, in the directory), but wants S3-access groups # managed by the app admin rather than the directory admin. That's useful # when directory admins are slow or conservative about adding new group # definitions. # # What this script creates: # #   1. Realm ($KC_REALM, default 'vast') #   2. Base public client ('vast-s3') for tooling that doesn't care about #      per-group restriction #   3. LDAP user federation pointing at the OpenLDAP container (same as #      the pure-LDAP variant — users sync from LDAP into Keycloak) #   4. ** NO ** group-LDAP-mapper. This is the key difference from the #      pure-LDAP variant. Groups in LDAP — if any — are ignored. #   5. Triggers an initial full LDAP sync (pulls users only, no groups) #   6. Four native Keycloak groups (rw-group, ro-group, wo-group, #      no-access-group) — managed entirely inside Keycloak #   7. Assigns each synced user to their matching native group, using the #      same username→group mapping the test matrix expects: #          rwuser1/2  → rw-group #          rouser1/2  → ro-group #          wouser1/2  → wo-group #          nauser1/2  → no-access-group #   8. 'groups' protocol mapper on the base client so JWTs carry native #      KC group names in a 'groups' claim # # Phase 2 (configure-keycloak-clients.sh) then creates the four per-group # clients whose conditional direct-grant flows gate token issuance on # native KC group membership. Phase 2 is unchanged across all three # variants — it looks groups up by name and doesn't care whether they # came from LDAP sync or were created natively. # # Idempotent: re-run safely. Each step looks up existing state first # and either skips or PATCHes. # # Required env (no default): #   KC_PASS    Keycloak admin password #   LDAP_PASS  OpenLDAP bind credential (used by Keycloak to bind to LDAP) # # Optional env: #   KC_URL          (default http://localhost:8080) #   KC_USER         (default admin) #   KC_REALM        (default vast) #   KC_FRONTEND_URL (default $KC_URL) #                   Realm frontendUrl — becomes the JWT 'iss' claim and #                   is what VAST uses to fetch JWKS. Must be an #                   IP/hostname reachable by the VAST cluster (not #                   'localhost'). Change this and the four trust policies #                   generated by create-iam.sh must stay in lockstep — #                   both scripts read the same variable. #   LDAP_URL        (default ldap://localhost:389) #                   LDAP URL Keycloak uses to bind. Override if Keycloak #                   runs containerized without --network=host. # # Hardcoded constants (match ldap-files/*.ldif): #   base client name   vast-s3 #   bind DN            cn=admin,dc=vast,dc=local #   users DN           ou=users,dc=vast,dc=local # # Users-only LDAP: this variant DELIBERATELY does not consume ldap-files/ # groups.ldif even if it's present in the repo. Loading it has no effect # here because there's no group-LDAP-mapper to import the groups. set -euo pipefail : "${KC_PASS:?set KC_PASS (Keycloak admin password)}" : "${LDAP_PASS:?set LDAP_PASS (OpenLDAP bind credential)}" KC_URL="${KC_URL:-http://localhost:8080}" KC_USER="${KC_USER:-admin}" KC_REALM="${KC_REALM:-vast}" KC_FRONTEND_URL="${KC_FRONTEND_URL:-${KC_URL}}" LDAP_URL="${LDAP_URL:-ldap://localhost:389}" KC_BASE_CLIENT="vast-s3" LDAP_BIND_DN="cn=admin,dc=vast,dc=local" LDAP_USERS_DN="ou=users,dc=vast,dc=local" # Four groups — names must match what configure-keycloak-clients.sh expects. GROUPS=(    "rw-group"    "ro-group"    "wo-group"    "no-access-group" ) # Username → group mapping. The usernames are the same as in the other two # variants (and the same as in ldap-files/users.ldif), so the shared test # matrix doesn't change. USERMAP=(    "rwuser1:rw-group"    "rwuser2:rw-group"    "rouser1:ro-group"    "rouser2:ro-group"    "wouser1:wo-group"    "wouser2:wo-group"    "nauser1:no-access-group"    "nauser2:no-access-group" ) API="${KC_URL}/admin/realms" #---------------------------------------------------------------------- # Auth + helpers #---------------------------------------------------------------------- echo "==> Authenticating to Keycloak ${KC_URL} as ${KC_USER}" TOKEN=$(curl -sf -X POST "${KC_URL}/realms/master/protocol/openid-connect/token" \    -d "username=${KC_USER}" \    -d "password=${KC_PASS}" \    -d "grant_type=password" \    -d "client_id=admin-cli" | jq -r '.access_token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then    echo "ERROR: failed to obtain Keycloak admin token" >&2    exit 1 fi echo "    OK (token: ${TOKEN:0:24}...)" api_get() {    curl -sk -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/json" "$@" } api_write() {    local method=$1 url=$2 body=${3:-}    local tmp    tmp=$(mktemp)    local code    if [ -n "$body" ]; then        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}" \            -H "Content-Type: application/json" \            -d "$body")    else        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}")    fi    if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then        echo "ERROR: ${method} ${url} returned HTTP ${code}" >&2        [ -n "$body" ] && echo "  Request body: $body" >&2        echo "  Response body:" >&2        cat "$tmp" >&2        echo >&2        rm -f "$tmp"        return 1    fi    cat "$tmp"    rm -f "$tmp" } #---------------------------------------------------------------------- # 1. Realm #---------------------------------------------------------------------- echo "==> Realm '${KC_REALM}'" realm_code=$(curl -sk -o /dev/null -w "%{http_code}" \    -H "Authorization: Bearer ${TOKEN}" \    "${API}/${KC_REALM}") if [ "$realm_code" = "404" ]; then    echo "    [realm] '${KC_REALM}' missing, POST"    body=$(jq -n \        --arg r "$KC_REALM" \        --arg fe "$KC_FRONTEND_URL" \        '{            realm: $r,            enabled: true,            sslRequired: "none",            attributes: { frontendUrl: $fe }        }')    api_write POST "${API}" "$body" >/dev/null elif [ "$realm_code" = "200" ]; then    echo "    [realm] '${KC_REALM}' exists" else    echo "ERROR: unexpected status ${realm_code} querying realm" >&2    exit 1 fi #---------------------------------------------------------------------- # 2. Base client #---------------------------------------------------------------------- echo "==> Base client '${KC_BASE_CLIENT}'" client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \    | jq -r '.[0].id // empty') if [ -z "$client_internal_id" ]; then    echo "    [client] '${KC_BASE_CLIENT}' missing, POST"    body=$(jq -n --arg c "$KC_BASE_CLIENT" '{        clientId: $c,        enabled: true,        publicClient: true,        directAccessGrantsEnabled: true,        standardFlowEnabled: true,        protocol: "openid-connect"    }')    api_write POST "${API}/${KC_REALM}/clients" "$body" >/dev/null    client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \        | jq -r '.[0].id // empty')    if [ -z "$client_internal_id" ]; then        echo "ERROR: created client but cannot resolve its internal id" >&2        exit 1    fi else    echo "    [client] '${KC_BASE_CLIENT}' exists" fi echo "          internal id=${client_internal_id}" #---------------------------------------------------------------------- # 3. LDAP user federation (users only — no group mapper) #---------------------------------------------------------------------- echo "==> LDAP user federation 'openldap' (users only)" ldap_id=$(api_get "${API}/${KC_REALM}/components?type=org.keycloak.storage.UserStorageProvider" \    | jq -r 'map(select(.providerId=="ldap" and .name=="openldap")) | .[0].id // empty') if [ -z "$ldap_id" ]; then    echo "    [federation] 'openldap' missing, POST"    body=$(jq -n \        --arg url "$LDAP_URL" \        --arg bind_dn "$LDAP_BIND_DN" \        --arg bind_pw "$LDAP_PASS" \        --arg users_dn "$LDAP_USERS_DN" \        --arg parent "$KC_REALM" \        '{            name: "openldap",            providerId: "ldap",            providerType: "org.keycloak.storage.UserStorageProvider",            parentId: $parent,            config: {                vendor: ["other"],                connectionUrl: [$url],                bindDn: [$bind_dn],                bindCredential: [$bind_pw],                usersDn: [$users_dn],                usernameLDAPAttribute: ["uid"],                rdnLDAPAttribute: ["uid"],                uuidLDAPAttribute: ["entryUUID"],                userObjectClasses: ["inetOrgPerson"],                editMode: ["READ_ONLY"],                syncRegistrations: ["false"],                trustEmail: ["true"],                enabled: ["true"],                importEnabled: ["true"]            }        }')    api_write POST "${API}/${KC_REALM}/components" "$body" >/dev/null    ldap_id=$(api_get "${API}/${KC_REALM}/components?type=org.keycloak.storage.UserStorageProvider" \        | jq -r 'map(select(.providerId=="ldap" and .name=="openldap")) | .[0].id // empty')    if [ -z "$ldap_id" ]; then        echo "ERROR: created LDAP federation but cannot resolve its id" >&2        exit 1    fi else    echo "    [federation] 'openldap' exists" fi echo "          id=${ldap_id}" echo "    [federation] NOTE: no group-ldap-mapper is created — groups in" echo "                 LDAP (if any) are deliberately ignored by this variant." #---------------------------------------------------------------------- # 4. Trigger a full LDAP sync (pulls users only — no group mapper exists, #    so any LDAP groups are not imported) #---------------------------------------------------------------------- echo "==> Triggering full LDAP user sync" sync_resp=$(api_write POST "${API}/${KC_REALM}/user-storage/${ldap_id}/sync?action=triggerFullSync") if [ -n "$sync_resp" ]; then    echo "$sync_resp" | jq -c '.' 2>/dev/null | sed 's/^/    /' || echo "    ${sync_resp}" else    echo "    (no body returned)" fi #---------------------------------------------------------------------- # 5. Native Keycloak groups #---------------------------------------------------------------------- echo "==> Native Keycloak groups" declare -A GROUP_IDS for gname in "${GROUPS[@]}"; do    gid=$(api_get "${API}/${KC_REALM}/groups?search=${gname}&exact=true" \        | jq -r --arg n "$gname" 'map(select(.name==$n)) | .[0].id // empty')    if [ -z "$gid" ]; then        echo "    [group] '${gname}' missing, POST"        body=$(jq -n --arg n "$gname" '{name:$n}')        api_write POST "${API}/${KC_REALM}/groups" "$body" >/dev/null        gid=$(api_get "${API}/${KC_REALM}/groups?search=${gname}&exact=true" \            | jq -r --arg n "$gname" 'map(select(.name==$n)) | .[0].id // empty')        if [ -z "$gid" ]; then            echo "ERROR: created group '${gname}' but cannot resolve its id" >&2            exit 1        fi    else        echo "    [group] '${gname}' exists"    fi    GROUP_IDS[$gname]=$gid    echo "          id=${gid}" done #---------------------------------------------------------------------- # 6. Assign synced users to native groups #    (The LDAP sync in step 4 imported the users as read-only federated #    users in Keycloak. They're real KC users now and can be added to #    native KC groups just like locally-created users.) #---------------------------------------------------------------------- echo "==> Assigning synced users to native groups" for row in "${USERMAP[@]}"; do    IFS=':' read -r uname grp <<<"$row"    uid=$(api_get "${API}/${KC_REALM}/users?username=${uname}&exact=true" \        | jq -r --arg n "$uname" 'map(select(.username==$n)) | .[0].id // empty')    if [ -z "$uid" ]; then        echo "    [user] '${uname}' not found in realm — did the LDAP sync succeed?"        echo "           Check that users.ldif loaded into OpenLDAP and re-run." >&2        exit 1    fi    gid=${GROUP_IDS[$grp]}    api_write PUT "${API}/${KC_REALM}/users/${uid}/groups/${gid}" "" >/dev/null    echo "    ${uname} -> ${grp} (uid=${uid:0:12}..., gid=${gid:0:12}...)" done #---------------------------------------------------------------------- # 7. 'groups' protocol mapper on the base client #---------------------------------------------------------------------- echo "==> 'groups' protocol mapper on '${KC_BASE_CLIENT}'" have_groups_mapper=$(api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq 'map(select(.name=="groups")) | length') if [ "$have_groups_mapper" -eq 0 ]; then    echo "    [mapper] 'groups' missing, POST"    body=$(jq -n '{        name: "groups",        protocol: "openid-connect",        protocolMapper: "oidc-group-membership-mapper",        config: {            "full.path": "false",            "id.token.claim": "true",            "access.token.claim": "true",            "claim.name": "groups",            "userinfo.token.claim": "true"        }    }')    api_write POST "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" "$body" >/dev/null else    echo "    [mapper] 'groups' exists" fi #---------------------------------------------------------------------- # Verification #---------------------------------------------------------------------- echo echo "==> Verification" echo "    Realm frontendUrl:" api_get "${API}/${KC_REALM}" | jq -r '"      " + (.attributes.frontendUrl // "(unset)")' echo "    Users in realm (all federated from LDAP):" api_get "${API}/${KC_REALM}/users?max=20" | jq -r '.[] | "      " + .username + "  (" + (.email // "no-email") + ")"' echo "    Groups in realm (all native, no LDAP origin):" api_get "${API}/${KC_REALM}/groups" | jq -r '.[] | "      " + .name' echo "    Membership:" for row in "${USERMAP[@]}"; do    IFS=':' read -r uname _ <<<"$row"    uid=$(api_get "${API}/${KC_REALM}/users?username=${uname}&exact=true" \        | jq -r --arg n "$uname" 'map(select(.username==$n)) | .[0].id // empty')    memb=$(api_get "${API}/${KC_REALM}/users/${uid}/groups" \        | jq -r 'map(.name) | join(",")')    echo "      ${uname} -> ${memb}" done echo "    Protocol mappers on '${KC_BASE_CLIENT}':" api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq -r '.[] | "      " + .name + " (" + .protocolMapper + ")"' echo echo "Done. Next: ./configure-keycloak-clients.sh" ``` #### **create-iam.sh** **create-iam.sh** ```bash #!/bin/bash # create-iam.sh — Reproducibly create the 4 identity policies + 4 IAM roles # that back the group-driven STS demo on a VAST cluster, via the VMS REST API. # Shared by the LDAP and local-user demo variants — VAST only sees the JWT, so # the same four policies + four roles work regardless of which Phase 1 script # populated Keycloak (configure-keycloak-ldap.sh or configure-keycloak-local.sh). # # Idempotent: re-running the script updates existing objects instead of duplicating. # # Object map (all in the tenant named in TENANT_NAME): # #   Identity policies                   | IAM roles | Trust policy gates JWT 'aud' on #   ------------------------------------+-----------+-------------------------------- #   rw-policy (s3:* on demo-bucket)     | rw-role   | vast-s3-rw #   ro-policy (read-only on demo-bucket)| ro-role   | vast-s3-ro #   wo-policy (write-only on demo-bckt) | wo-role   | vast-s3-wo #   na-policy (Deny *)                  | na-role   | vast-s3-na # # The four 'aud' values match the four per-group Keycloak client IDs created # by configure-keycloak-clients.sh — they are how group identity is projected # onto the JWT (see KB.md "Vocabulary" for why we project onto aud and not # directly onto a 'groups' claim). # # Identity policies are read verbatim from iam-roles/-identity-policy.json. # Trust policies are *generated in-script* from the resolved OIDC issuer URL # and the matching aud value — see build_trust_policy() below. There are no # separate trust-policy JSON files: every trust policy in this demo is # structurally identical except for the issuer + aud, and stamping them out # in shell is more honest than checking in four near-identical files with a # baked-in host IP that any reproducer would have to edit. # # Required env vars: #   VMS_HOST    — VMS management hostname/IP   (e.g. vms.example.com) #   VMS_USER    — VMS admin user                (e.g. admin) #   VMS_PASS    — VMS admin password #   TENANT_NAME — Tenant to scope objects to   (your tenant on the cluster) # # Optional env vars: #   MAX_SESSION_DURATION  Maximum STS credential lifetime, in seconds, baked #                         into each IAM role. VAST enforces a floor of 900 #                         (15 minutes) and defaults to 129600 (36 hours) #                         when the field is omitted. This demo sets 900 — #                         the tightest possible value — because a shorter #                         lifetime reduces the blast radius of a leaked #                         session. A client requesting a longer session via #                         'aws sts assume-role-with-web-identity #                         --duration-seconds ' will be rejected when N #                         exceeds this value. Override for workflows that #                         can't re-auth mid-run: #                           export MAX_SESSION_DURATION=3600   # 1 hour # # OIDC issuer — pick *one* of the following: #   OIDC_ISSUER       The literal authority + realm path baked into the trust #                     policies, in the form  host:port/realms/ #                     (no scheme, no leading slash, no trailing slash). #                     Example:  :8080/realms/vast #   KC_FRONTEND_URL   The realm's frontendUrl as set by your Phase 1 script #                     (configure-keycloak-ldap.sh or configure-keycloak-local.sh). #                     Combined with KC_REALM (default 'vast') to derive #                     OIDC_ISSUER. If unset we fall back to KC_URL. #                     Example:  http://:8080 # # Whatever issuer this script uses MUST match the iss claim Keycloak embeds # in the JWTs your users will obtain. If they don't match, VAST will reject # AssumeRoleWithWebIdentity with a 403. # # Usage: #   export VMS_HOST=vms.example.com #   export VMS_USER=admin #   export VMS_PASS='...' #   export TENANT_NAME=your-tenant-name #   export KC_FRONTEND_URL=http://:8080   # or set OIDC_ISSUER directly #   ./create-iam.sh set -euo pipefail : "${VMS_HOST:?set VMS_HOST}" : "${VMS_USER:?set VMS_USER}" : "${VMS_PASS:?set VMS_PASS}" : "${TENANT_NAME:?set TENANT_NAME}" KC_REALM="${KC_REALM:-vast}" MAX_SESSION_DURATION="${MAX_SESSION_DURATION:-900}" # Resolve the OIDC issuer the trust policies will reference. Either set # OIDC_ISSUER directly, or set KC_FRONTEND_URL (preferred — same var the # Phase 1 script uses) or KC_URL and we'll derive it. if [ -z "${OIDC_ISSUER:-}" ]; then    fe="${KC_FRONTEND_URL:-${KC_URL:-}}"    if [ -z "$fe" ]; then        echo "ERROR: set OIDC_ISSUER, KC_FRONTEND_URL, or KC_URL so the trust policies know which Keycloak to trust" >&2        exit 1    fi    issuer_authority="${fe#http://}"    issuer_authority="${issuer_authority#https://}"    issuer_authority="${issuer_authority%/}"    OIDC_ISSUER="${issuer_authority}/realms/${KC_REALM}"    echo "==> Derived OIDC issuer from ${fe} + realm '${KC_REALM}': ${OIDC_ISSUER}" else    echo "==> Using OIDC issuer: ${OIDC_ISSUER}" fi API="https://${VMS_HOST}/api" SCRIPT_DIR="$(cd"$(dirname "${BASH_SOURCE[0]}")" && pwd)" IAM_DIR="${SCRIPT_DIR}/iam-roles" # (role_name, identity_policy_name, jwt_aud_value) # The aud values must match the per-group Keycloak client IDs created by # configure-keycloak-clients.sh. ROLES=(    "rw-role:rw-policy:vast-s3-rw"    "ro-role:ro-policy:vast-s3-ro"    "wo-role:wo-policy:vast-s3-wo"    "na-role:na-policy:vast-s3-na" ) #---------------------------------------------------------------------------- # Auth #---------------------------------------------------------------------------- echo "==> Authenticating to ${API}/token/ as ${VMS_USER}" AUTH_BODY=$(jq -n --arg u "$VMS_USER" --arg p "$VMS_PASS" '{username:$u, password:$p}') TOKEN=$(curl -sk -X POST "${API}/token/" \    -H "Content-Type: application/json" \    -d "$AUTH_BODY" | jq -r '.access') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then    echo "ERROR: failed to obtain access token" >&2    exit 1 fi echo "    OK (token: ${TOKEN:0:24}...)" # Convenience: curl wrapper that always sends auth + json (used for GETs) api_curl() {    curl -sk \        -H "Authorization: Bearer ${TOKEN}" \        -H "Content-Type: application/json" \        "$@" } # api_write METHOD URL BODY # Sends a POST/PATCH/PUT, captures both body and HTTP status. On non-2xx, # prints the body and exits non-zero. On success, prints the response body # to stdout. api_write() {    local method=$1    local url=$2    local body=$3    local tmp    tmp=$(mktemp)    local code    code=$(curl -sk -o "$tmp" -w "%{http_code}" \        -X "$method" "$url" \        -H "Authorization: Bearer ${TOKEN}" \        -H "Content-Type: application/json" \        -d "$body")    if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then        echo "ERROR: ${method} ${url} returned HTTP ${code}" >&2        echo "  Request body: $body" >&2        echo "  Response body:" >&2        cat "$tmp" >&2        echo >&2        rm -f "$tmp"        return 1    fi    cat "$tmp"    rm -f "$tmp" } #---------------------------------------------------------------------------- # Resolve tenant_id #---------------------------------------------------------------------------- echo "==> Resolving tenant '${TENANT_NAME}'" TENANT_ID=$(api_curl "${API}/tenants/?name=${TENANT_NAME}" \    | jq -r --arg n "$TENANT_NAME" '        (if type=="array" then . elif has("results") then .results else [.] end)        | map(select(.name==$n))        | .[0].id // empty      ') if [ -z "$TENANT_ID" ]; then    echo "ERROR: tenant '${TENANT_NAME}' not found" >&2    exit 1 fi echo "    tenant_id=${TENANT_ID}" #---------------------------------------------------------------------------- # Trust policy generator #---------------------------------------------------------------------------- # build_trust_policy AUD # Generates the trust policy JSON for one IAM role, using the OIDC_ISSUER # resolved at script start and the supplied JWT 'aud' value. Prints the # rendered JSON to stdout. # # VAST quirks baked in: #   - Federated principal is "oidc-provider/" (the "oidc-provider/" #     prefix is VAST's literal notation for an OIDC federated principal) #   - Condition key is ":aud" — the JWT 'aud' claim, namespaced #     by the issuer URL #   - Only aud/sub/email/azp work as condition keys in VAST trust policies; #     we use aud because that's where the per-group client architecture #     projects group identity (see KB.md) # # Rendered shape: #   { #       "Version": "2012-10-17", #       "Statement": [{ #           "Effect": "Allow", #           "Principal": { "Federated": "oidc-provider/" }, #           "Action": "sts:AssumeRoleWithWebIdentity", #           "Condition": { #               "StringEquals": { ":aud": "" } #           } #       }] #   } build_trust_policy() {    local jwt_aud=$1    jq -n \        --arg fed "oidc-provider/${OIDC_ISSUER}" \        --arg ck "${OIDC_ISSUER}:aud" \        --arg aud "$jwt_aud" \        '{            Version: "2012-10-17",            Statement: [{                Effect: "Allow",                Principal: { Federated: $fed },                Action: "sts:AssumeRoleWithWebIdentity",                Condition: {                    StringEquals: { ($ck): $aud }                }            }]        }' } #---------------------------------------------------------------------------- # Step 1: identity policies (s3policies) #---------------------------------------------------------------------------- # Returns the policy id, creating or updating as needed. upsert_policy() {    local pname=$1    local pfile=$2    if [ ! -f "$pfile" ]; then        echo "ERROR: identity policy file not found: $pfile" >&2        return 1    fi    # Compact the JSON document into a single-line string for the 'policy' field    local policy_str    policy_str=$(jq -c . < "$pfile")    # Look up existing policy by name within this tenant    local existing_id    existing_id=$(api_curl "${API}/s3policies/?name=${pname}&tenant_id=${TENANT_ID}" \        | jq -r --arg n "$pname" --argjson t "$TENANT_ID" '            (if type=="array" then . elif has("results") then .results else [.] end)            | map(select(.name==$n and .tenant_id==$t))            | .[0].id // empty          ')    if [ -n "$existing_id" ]; then        # PATCH: omit name/tenant_id — VMS re-runs the uniqueness check on the        # incoming name without excluding the current row, so re-sending the        # name returns 400 "Policy name should be unique per Tenant".        local patch_body        patch_body=$(jq -n \            --arg policy "$policy_str" \            '{policy:$policy, enabled:true, is_replicated:false}')        echo "    [policy] '${pname}' exists (id=${existing_id}), PATCH" >&2        api_write PATCH "${API}/s3policies/${existing_id}/" "$patch_body" >/dev/null || return 1        echo "$existing_id"    else        local post_body        post_body=$(jq -n \            --arg name "$pname" \            --arg policy "$policy_str" \            --argjson tenant_id "$TENANT_ID" \            '{name:$name, policy:$policy, tenant_id:$tenant_id, enabled:true, is_replicated:false}')        echo "    [policy] '${pname}' missing, POST" >&2        local resp        resp=$(api_write POST "${API}/s3policies/" "$post_body") || return 1        echo "$resp" | jq -r '.id'    fi } #---------------------------------------------------------------------------- # Step 2: IAM roles #---------------------------------------------------------------------------- upsert_role() {    local rname=$1    local jwt_aud=$2    local policy_id=$3    # Render the trust policy fresh from the resolved OIDC issuer + this aud.    local trust_str    trust_str=$(build_trust_policy "$jwt_aud" | jq -c .)    local existing_id    existing_id=$(api_curl "${API}/iamroles/?name=${rname}" \        | jq -r --arg n "$rname" --argjson t "$TENANT_ID" '            (if type=="array" then . elif has("results") then .results else [.] end)            | map(select(.name==$n and .tenant_id==$t))            | .[0].id // empty          ')    if [ -n "$existing_id" ]; then        # PATCH: omit name to dodge the same uniqueness-check quirk seen on s3policies.        local patch_body        patch_body=$(jq -n \            --arg trust "$trust_str" \            --argjson pid "$policy_id" \            --arg msd "$MAX_SESSION_DURATION" \            '{trust_policy:$trust, s3_policies:[$pid], max_session_duration:$msd}')        echo "    [role] '${rname}' exists (id=${existing_id}), PATCH (max_session_duration=${MAX_SESSION_DURATION}s)" >&2        api_write PATCH "${API}/iamroles/${existing_id}/" "$patch_body" >/dev/null || return 1    else        local post_body        post_body=$(jq -n \            --arg name "$rname" \            --arg trust "$trust_str" \            --argjson tenant_id "$TENANT_ID" \            --argjson pid "$policy_id" \            --arg msd "$MAX_SESSION_DURATION" \            '{name:$name, tenant_id:$tenant_id, trust_policy:$trust, s3_policies:[$pid], max_session_duration:$msd}')        echo "    [role] '${rname}' missing, POST (max_session_duration=${MAX_SESSION_DURATION}s)" >&2        api_write POST "${API}/iamroles/" "$post_body" >/dev/null || return 1    fi } #---------------------------------------------------------------------------- # Run #---------------------------------------------------------------------------- echo "==> Upserting identity policies" declare -A POLICY_IDS for entry in "${ROLES[@]}"; do    IFS=':' read -r role_name policy_name jwt_aud <<<"$entry"    pfile="${IAM_DIR}/${role_name%-role}-identity-policy.json"    pid=$(upsert_policy "$policy_name" "$pfile")    POLICY_IDS[$policy_name]=$pid    echo "      ${policy_name} -> id=${pid}" done echo "==> Upserting IAM roles" for entry in "${ROLES[@]}"; do    IFS=':' read -r role_name policy_name jwt_aud <<<"$entry"    pid=${POLICY_IDS[$policy_name]}    upsert_role "$role_name" "$jwt_aud" "$pid" done #---------------------------------------------------------------------------- # Verify #---------------------------------------------------------------------------- echo echo "==> Verification: roles in tenant ${TENANT_NAME}" api_curl "${API}/iamroles/" | jq --argjson t "$TENANT_ID" '    map(select(.tenant_id==$t and (.name | test("^(rw|ro|wo|na)-role$"))))    | map({id, name, trust_policy}) ' echo echo "==> Verification: policies in tenant ${TENANT_NAME}" api_curl "${API}/s3policies/?tenant_id=${TENANT_ID}" | jq '    (if type=="array" then . elif has("results") then .results else [.] end)    | map(select(.name | test("^(rw|ro|wo|na)-policy$")))    | map({id, name, attached_to: .users}) ' echo echo "Done." ``` #### **test-ldap-users-local-groups-sts.sh** **test-ldap-users-local-groups-sts.sh** ```bash #!/bin/bash # test-ldap-users-local-groups-sts.sh — End-to-end test for the hybrid # variant (LDAP-federated users, native Keycloak groups). # # Identical to the other two variants' test scripts — the OIDC → STS → S3 # chain doesn't care where user identity or group membership came from. # For each Keycloak user we: #   1. Request a JWT from the matching per-group Keycloak client (vast-s3-XX). #      Token issuance is gated server-side by a conditional-user-role check #      against the realm role mapped from the user's native Keycloak group #      (which the Phase 1 script assigned after the initial LDAP user sync). #   2. Trade the JWT for VAST temporary creds via STS, assuming the matching #      per-group IAM role (XX-role). The role's trust policy gates on #      :aud == vast-s3-XX, so the right JWT can only assume the #      right role. #   3. Run a PUT and a GET against demo-bucket. Identity policies attached to #      each role decide what the resulting session can actually do. # # Plus one negative test: a known-non-rw user (rouser1) is asked to fetch a # token from vast-s3-rw. This MUST fail at Keycloak (Deny Access fires inside # the per-client direct grant flow), proving group membership is load-bearing. # # Expected matrix: #   rwuser*  vast-s3-rw  rw-role  PUT allow  GET allow #   rouser*  vast-s3-ro  ro-role  PUT deny   GET allow #   wouser*  vast-s3-wo  wo-role  PUT allow  GET deny #   nauser*  vast-s3-na  na-role  PUT deny   GET deny #   rouser1  vast-s3-rw  (token request itself must FAIL) KC_URL="${KC_URL:-http://localhost:8080}" KC_REALM="${KC_REALM:-vast}" VAST_VIP="${VAST_VIP:?set VAST_VIP (S3 data VIP on your tenant)}" TENANT_NAME="${TENANT_NAME:?set TENANT_NAME}" BUCKET="${BUCKET:-demo-bucket}" PASSWORD="${PASSWORD:?set PASSWORD in env (the LDAP demo-user password — the value in ldap-files/users.ldif)}" SEED_KEY="seed-from-rw.txt" PASS=0 FAIL=0 record() {    if [ "$1" = "PASS" ]; then        PASS=$((PASS+1))        echo "  RESULT: PASS — $2"    else        FAIL=$((FAIL+1))        echo "  RESULT: FAIL — $2"    fi } # get_token # echoes the id_token, or empty string if Keycloak refused. get_token() {    local username=$1 client_id=$2    curl -s -X POST "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \        -d "grant_type=password" \        -d "client_id=${client_id}" \        -d "username=${username}" \        -d "password=${PASSWORD}" \        -d "scope=openid" \        | jq -r '.id_token // empty' } # assume_role # Sets AWS_* env vars on success, returns 1 on failure. assume_role() {    local username=$1 client_id=$2 role_name=$3    local role_arn="arn:vast::${TENANT_NAME}:role/${role_name}"    local id_token    id_token=$(get_token "$username" "$client_id")    if [ -z "$id_token" ]; then        echo "  ERROR: Keycloak refused to issue a token for ${username} from ${client_id}" >&2        return 1    fi    echo "  Token claims:" >&2    echo "$id_token" | cut -d'.' -f2 | base64 -d 2>/dev/null \        | jq '{aud, email, preferred_username, groups}' >&2    local sts    # Discard stderr (urllib3 InsecureRequestWarning) — merging it with stdout    # poisons the JSON for jq.    sts=$(aws sts assume-role-with-web-identity \        --role-arn "$role_arn" \        --role-session-name "${username}-session" \        --web-identity-token "$id_token" \        --endpoint-url "https://${VAST_VIP}" \        --no-verify-ssl 2>/dev/null)    local ak    ak=$(echo "$sts" | jq -r '.Credentials.AccessKeyId // empty' 2>/dev/null)    if [ -z "$ak" ]; then        echo "  ERROR: STS assume-role failed for ${username}/${role_name}" >&2        echo "  raw stdout: $sts" >&2        return 1    fi    export AWS_ACCESS_KEY_ID=$ak    export AWS_SECRET_ACCESS_KEY=$(echo "$sts" | jq -r '.Credentials.SecretAccessKey')    export AWS_SESSION_TOKEN=$(echo "$sts" | jq -r '.Credentials.SessionToken')    echo "  STS AccessKeyId: $AWS_ACCESS_KEY_ID" >&2 } clear_creds() {    unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } attempt_put() {    if aws s3 cp "$1" "s3://${BUCKET}/$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } attempt_get() {    if aws s3 cp "s3://${BUCKET}/$1" "$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } # Generic per-user test. Args: USERNAME CLIENT_ID ROLE_NAME EXPECT_PUT EXPECT_GET test_user() {    local username=$1 client_id=$2 role_name=$3 expect_put=$4 expect_get=$5    echo    echo "============================================"    echo "  ${username} via ${client_id} → ${role_name}"    echo "  expect: PUT ${expect_put}, GET ${expect_get}"    echo "============================================"    assume_role "$username" "$client_id" "$role_name" || { record FAIL "STS"; return; }    local payload="/tmp/sts-test-${username}.txt"    echo "Hello from ${username} (ldap-users + local-groups variant) $(date)" > "$payload"    local put_result    put_result=$(attempt_put "$payload" "${username}.txt")    if [ "$put_result" = "$expect_put" ]; then        record PASS "PUT was ${put_result} as expected"    else        record FAIL "PUT was ${put_result}, expected ${expect_put}"    fi    if [ "$username" = "rwuser1" ]; then        echo "seed object placed by ${username} on $(date)" \          | aws s3 cp - "s3://${BUCKET}/${SEED_KEY}" \            --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1 \            && echo "  Seeded ${SEED_KEY}"    fi    local get_result    get_result=$(attempt_get "$SEED_KEY" "/tmp/sts-download-${username}.txt")    if [ "$get_result" = "$expect_get" ]; then        if [ "$get_result" = "allow" ]; then            record PASS "GET ${SEED_KEY} returned: $(cat /tmp/sts-download-${username}.txt)"        else            record PASS "GET was denied as expected"        fi    else        record FAIL "GET was ${get_result}, expected ${expect_get}"    fi    clear_creds } # rwuser1 must seed the test object first. test_user rwuser1 vast-s3-rw rw-role allow allow test_user rwuser2 vast-s3-rw rw-role allow allow test_user rouser1 vast-s3-ro ro-role deny  allow test_user rouser2 vast-s3-ro ro-role deny  allow test_user wouser1 vast-s3-wo wo-role allow deny test_user wouser2 vast-s3-wo wo-role allow deny test_user nauser1 vast-s3-na na-role deny  deny test_user nauser2 vast-s3-na na-role deny  deny # NEGATIVE TEST: rouser1 tries to get a token from vast-s3-rw. # Token issuance MUST fail because rouser1 lacks the vast-s3-rw realm role. echo echo "============================================" echo "  NEGATIVE: rouser1 → vast-s3-rw client" echo "  expect: Keycloak refuses to issue a token" echo "============================================" neg_token=$(get_token "rouser1" "vast-s3-rw") if [ -z "$neg_token" ]; then    record PASS "Keycloak refused (group is load-bearing)" else    record FAIL "Keycloak issued a token to rouser1 from vast-s3-rw — group gate broken" fi echo echo "============================================" echo "  Summary: ${PASS} passed, ${FAIL} failed" echo "  (17 expected — 16 access-matrix + 1 negative)" echo "============================================" [ "$FAIL" -eq 0 ] ``` #### configure-keycloak-ldap.sh **configure-keycloak-ldap.sh** ```bash #!/bin/bash # configure-keycloak-ldap.sh — Phase 1 Keycloak setup. # # Wires up the Keycloak realm that the rest of the demo plugs into: # #   1. Realm  ($KC_REALM, default 'vast') #   2. Base public client  ('vast-s3') for tooling that doesn't care #      about per-group restriction #   3. LDAP user federation pointing at the OpenLDAP container #   4. Group LDAP mapper so the four LDIF groups (rw-group, ro-group, #      wo-group, no-access-group) appear as Keycloak groups #   5. Triggers an initial full LDAP sync #   6. Adds a 'groups' protocol mapper to the base client so JWTs #      carry LDAP group names in a 'groups' claim # # Phase 2 (configure-keycloak-clients.sh) then creates the four # per-group clients whose conditional flows gate token issuance on # LDAP group membership. Run this script first. # # Idempotent: re-run safely. Each step looks up existing state first # and either skips or PATCHes — same pattern as create-iam.sh. # # Required env (no default): #   KC_PASS    Keycloak admin password #   LDAP_PASS  OpenLDAP bind credential (used by Keycloak to bind to LDAP) # # Optional env: #   KC_URL          (default http://localhost:8080) #                   Keycloak admin endpoint that this script talks to. #   KC_USER         (default admin) #                   Keycloak admin username. #   KC_REALM        (default vast) #                   Realm name to create / update. #   KC_FRONTEND_URL (default $KC_URL) #                   Realm frontendUrl — becomes the JWT 'iss' claim and #                   is what VAST uses to fetch JWKS. If you change this, #                   also update the four trust-policy JSONs in iam-roles/ #                   so the issuer half of the condition keys still #                   matches. The default works only if VAST can reach #                   Keycloak at the same URL the script uses (i.e. #                   you're running everything on one host). #   LDAP_URL        (default ldap://localhost:389) #                   LDAP URL Keycloak uses to bind. The default works #                   only if Keycloak runs with --network=host or is not #                   containerized; otherwise override to your host's #                   reachable address (e.g. ldap://192.168.1.10:389). # # Hardcoded constants (match ldap-files/*.ldif — change these and the # LDIFs together or not at all): #   base client name   vast-s3 #   bind DN            cn=admin,dc=vast,dc=local #   users DN           ou=users,dc=vast,dc=local #   groups DN          ou=groups,dc=vast,dc=local set -euo pipefail : "${KC_PASS:?set KC_PASS (Keycloak admin password)}" : "${LDAP_PASS:?set LDAP_PASS (OpenLDAP bind credential)}" KC_URL="${KC_URL:-http://localhost:8080}" KC_USER="${KC_USER:-admin}" KC_REALM="${KC_REALM:-vast}" KC_FRONTEND_URL="${KC_FRONTEND_URL:-${KC_URL}}" LDAP_URL="${LDAP_URL:-ldap://localhost:389}" # Constants — these match what's in ldap-files/ and what the rest of # the demo expects. KC_BASE_CLIENT="vast-s3" LDAP_BIND_DN="cn=admin,dc=vast,dc=local" LDAP_USERS_DN="ou=users,dc=vast,dc=local" LDAP_GROUPS_DN="ou=groups,dc=vast,dc=local" API="${KC_URL}/admin/realms" #---------------------------------------------------------------------- # Auth + helpers #---------------------------------------------------------------------- echo "==> Authenticating to Keycloak ${KC_URL} as ${KC_USER}" TOKEN=$(curl -sf -X POST "${KC_URL}/realms/master/protocol/openid-connect/token" \    -d "username=${KC_USER}" \    -d "password=${KC_PASS}" \    -d "grant_type=password" \    -d "client_id=admin-cli" | jq -r '.access_token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then    echo "ERROR: failed to obtain Keycloak admin token" >&2    exit 1 fi echo "    OK (token: ${TOKEN:0:24}...)" # api_get URL... # GET with auth + Accept JSON. Prints the body to stdout. Callers handle # empty / non-JSON responses. api_get() {    curl -sk -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/json" "$@" } # api_write METHOD URL [BODY] # Sends a POST/PATCH/PUT, captures both body and HTTP status. On non-2xx, # prints the body and returns non-zero. On success, prints the response # body to stdout (which may be empty for 201/204 responses). api_write() {    local method=$1 url=$2 body=${3:-}    local tmp    tmp=$(mktemp)    local code    if [ -n "$body" ]; then        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}" \            -H "Content-Type: application/json" \            -d "$body")    else        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}")    fi    if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then        echo "ERROR: ${method} ${url} returned HTTP ${code}" >&2        [ -n "$body" ] && echo "  Request body: $body" >&2        echo "  Response body:" >&2        cat "$tmp" >&2        echo >&2        rm -f "$tmp"        return 1    fi    cat "$tmp"    rm -f "$tmp" } #---------------------------------------------------------------------- # 1. Realm #---------------------------------------------------------------------- echo "==> Realm '${KC_REALM}'" realm_code=$(curl -sk -o /dev/null -w "%{http_code}" \    -H "Authorization: Bearer ${TOKEN}" \    "${API}/${KC_REALM}") if [ "$realm_code" = "404" ]; then    echo "    [realm] '${KC_REALM}' missing, POST"    body=$(jq -n \        --arg r "$KC_REALM" \        --arg fe "$KC_FRONTEND_URL" \        '{            realm: $r,            enabled: true,            sslRequired: "none",            attributes: { frontendUrl: $fe }        }')    api_write POST "${API}" "$body" >/dev/null elif [ "$realm_code" = "200" ]; then    echo "    [realm] '${KC_REALM}' exists" else    echo "ERROR: unexpected status ${realm_code} querying realm" >&2    exit 1 fi #---------------------------------------------------------------------- # 2. Base client #---------------------------------------------------------------------- echo "==> Base client '${KC_BASE_CLIENT}'" client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \    | jq -r '.[0].id // empty') if [ -z "$client_internal_id" ]; then    echo "    [client] '${KC_BASE_CLIENT}' missing, POST"    body=$(jq -n --arg c "$KC_BASE_CLIENT" '{        clientId: $c,        enabled: true,        publicClient: true,        directAccessGrantsEnabled: true,        standardFlowEnabled: true,        protocol: "openid-connect"    }')    api_write POST "${API}/${KC_REALM}/clients" "$body" >/dev/null    client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \        | jq -r '.[0].id // empty')    if [ -z "$client_internal_id" ]; then        echo "ERROR: created client but cannot resolve its internal id" >&2        exit 1    fi else    echo "    [client] '${KC_BASE_CLIENT}' exists" fi echo "          internal id=${client_internal_id}" #---------------------------------------------------------------------- # 3. LDAP user federation #---------------------------------------------------------------------- echo "==> LDAP user federation 'openldap'" ldap_id=$(api_get "${API}/${KC_REALM}/components?type=org.keycloak.storage.UserStorageProvider" \    | jq -r 'map(select(.providerId=="ldap" and .name=="openldap")) | .[0].id // empty') if [ -z "$ldap_id" ]; then    echo "    [federation] 'openldap' missing, POST"    body=$(jq -n \        --arg url "$LDAP_URL" \        --arg bind_dn "$LDAP_BIND_DN" \        --arg bind_pw "$LDAP_PASS" \        --arg users_dn "$LDAP_USERS_DN" \        --arg parent "$KC_REALM" \        '{            name: "openldap",            providerId: "ldap",            providerType: "org.keycloak.storage.UserStorageProvider",            parentId: $parent,            config: {                vendor: ["other"],                connectionUrl: [$url],                bindDn: [$bind_dn],                bindCredential: [$bind_pw],                usersDn: [$users_dn],                usernameLDAPAttribute: ["uid"],                rdnLDAPAttribute: ["uid"],                uuidLDAPAttribute: ["entryUUID"],                userObjectClasses: ["inetOrgPerson"],                editMode: ["READ_ONLY"],                syncRegistrations: ["false"],                trustEmail: ["true"],                enabled: ["true"],                importEnabled: ["true"]            }        }')    api_write POST "${API}/${KC_REALM}/components" "$body" >/dev/null    ldap_id=$(api_get "${API}/${KC_REALM}/components?type=org.keycloak.storage.UserStorageProvider" \        | jq -r 'map(select(.providerId=="ldap" and .name=="openldap")) | .[0].id // empty')    if [ -z "$ldap_id" ]; then        echo "ERROR: created LDAP federation but cannot resolve its id" >&2        exit 1    fi else    echo "    [federation] 'openldap' exists" fi echo "          id=${ldap_id}" #---------------------------------------------------------------------- # 4. Group LDAP mapper (under the LDAP federation component) #---------------------------------------------------------------------- echo "==> LDAP group mapper 'ldap-group-mapper'" mapper_id=$(api_get "${API}/${KC_REALM}/components?type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&parent=${ldap_id}" \    | jq -r 'map(select(.name=="ldap-group-mapper")) | .[0].id // empty') if [ -z "$mapper_id" ]; then    echo "    [mapper] 'ldap-group-mapper' missing, POST"    body=$(jq -n \        --arg parent "$ldap_id" \        --arg groups_dn "$LDAP_GROUPS_DN" \        '{            name: "ldap-group-mapper",            providerId: "group-ldap-mapper",            providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",            parentId: $parent,            config: {                "groups.dn": [$groups_dn],                "group.name.ldap.attribute": ["cn"],                "group.object.classes": ["groupOfNames"],                "preserve.group.inheritance": ["false"],                "membership.ldap.attribute": ["member"],                "membership.attribute.type": ["DN"],                "membership.user.ldap.attribute": ["uid"],                "groups.ldap.filter": [""],                "mode": ["READ_ONLY"],                "user.roles.retrieve.strategy": ["LOAD_GROUPS_BY_MEMBER_ATTRIBUTE"],                "memberof.ldap.attribute": ["memberOf"],                "drop.non.existing.groups.during.sync": ["false"]            }        }')    api_write POST "${API}/${KC_REALM}/components" "$body" >/dev/null else    echo "    [mapper] 'ldap-group-mapper' exists" fi #---------------------------------------------------------------------- # 5. Trigger a full LDAP sync (naturally idempotent — re-running just #    re-imports and is safe) #---------------------------------------------------------------------- echo "==> Triggering full LDAP user sync" sync_resp=$(api_write POST "${API}/${KC_REALM}/user-storage/${ldap_id}/sync?action=triggerFullSync") if [ -n "$sync_resp" ]; then    echo "$sync_resp" | jq -c '.' 2>/dev/null | sed 's/^/    /' || echo "    ${sync_resp}" else    echo "    (no body returned)" fi #---------------------------------------------------------------------- # 6. 'groups' protocol mapper on the base client #---------------------------------------------------------------------- echo "==> 'groups' protocol mapper on '${KC_BASE_CLIENT}'" have_groups_mapper=$(api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq 'map(select(.name=="groups")) | length') if [ "$have_groups_mapper" -eq 0 ]; then    echo "    [mapper] 'groups' missing, POST"    body=$(jq -n '{        name: "groups",        protocol: "openid-connect",        protocolMapper: "oidc-group-membership-mapper",        config: {            "full.path": "false",            "id.token.claim": "true",            "access.token.claim": "true",            "claim.name": "groups",            "userinfo.token.claim": "true"        }    }')    api_write POST "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" "$body" >/dev/null else    echo "    [mapper] 'groups' exists" fi #---------------------------------------------------------------------- # Verification #---------------------------------------------------------------------- echo echo "==> Verification" echo "    Realm frontendUrl:" api_get "${API}/${KC_REALM}" | jq -r '"      " + (.attributes.frontendUrl // "(unset)")' echo "    Users in realm:" api_get "${API}/${KC_REALM}/users?max=20" | jq -r '.[] | "      " + .username' echo "    Groups in realm:" api_get "${API}/${KC_REALM}/groups" | jq -r '.[] | "      " + .name' echo "    Protocol mappers on '${KC_BASE_CLIENT}':" api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq -r '.[] | "      " + .name + " (" + .protocolMapper + ")"' echo echo "Done." ``` #### configure-keycloak-local.sh **configure-keycloak-local.sh** ```bash #!/bin/bash # configure-keycloak-local.sh — Phase 1 Keycloak setup (local users + local groups). # # Mirrors the LDAP-path demo's Phase 1 (configure-keycloak-ldap.sh) but keeps # everything inside Keycloak — no OpenLDAP, no user federation, no group mapper. # Wires up the Keycloak realm that the rest of the demo plugs into: # #   1. Realm ($KC_REALM, default 'vast') with frontendUrl #   2. Base public client ('vast-s3') for tooling that doesn't care about #      per-group restriction (e.g. a quick 'does my token even work' test) #   3. Four native KC groups: rw-group, ro-group, wo-group, no-access-group #   4. Eight local users (rwuser1/2, rouser1/2, wouser1/2, nauser1/2) each #      assigned to their matching group. Same usernames and group names as #      the LDAP-path demo, so the Phase 2 script and the test matrix are #      unchanged. #   5. 'groups' protocol mapper on the base client so JWTs carry group #      names in a 'groups' claim (nice for debugging; not load-bearing). # # Phase 2 (configure-keycloak-clients.sh) then creates the four per-group # clients whose conditional direct-grant flows gate token issuance on group # membership. Run this script first. # # Keycloak 26.x gotcha — users need three API calls each: #   a) POST /users          — create (username + enabled ONLY; KC 26.x silently #                             ignores email/firstName/lastName/credentials when #                             passed inline) #   b) PUT  /users/{id}     — set email + firstName + lastName + emailVerified #                             (required: the realm's default user profile #                             enforces firstName+lastName or login fails with #                             "Account is not fully set up") #   c) PUT  /users/{id}/reset-password — set the password, temporary=false # # Idempotent: re-run safely. Each step looks up existing state first and # either skips or PATCHes. # # Required env (no default): #   KC_PASS   Keycloak admin password #   PASSWORD  Password to set on every demo user (the test scripts read the #             same variable) # # Optional env: #   KC_URL          (default http://localhost:8080) #                   Keycloak admin endpoint that this script talks to. #   KC_USER         (default admin) #                   Keycloak admin username. #   KC_REALM        (default vast) #                   Realm name to create / update. #   KC_FRONTEND_URL (default $KC_URL) #                   Realm frontendUrl — becomes the JWT 'iss' claim and is #                   what VAST uses to fetch JWKS. Must be an IP/hostname #                   reachable by the VAST cluster (not 'localhost'). Change #                   this and the four trust policies generated by #                   create-iam.sh must stay in lockstep — both scripts read #                   the same variable, so setting it once is enough. set -euo pipefail : "${KC_PASS:?set KC_PASS (Keycloak admin password)}" : "${PASSWORD:?set PASSWORD (the password every demo user will get)}" KC_URL="${KC_URL:-http://localhost:8080}" KC_USER="${KC_USER:-admin}" KC_REALM="${KC_REALM:-vast}" KC_FRONTEND_URL="${KC_FRONTEND_URL:-${KC_URL}}" KC_BASE_CLIENT="vast-s3" # Four groups — names must match what configure-keycloak-clients.sh expects. GROUPS=(    "rw-group"    "ro-group"    "wo-group"    "no-access-group" ) # Eight users — username:group:firstName:lastName. The firstName/lastName pair # exists purely to satisfy the KC 26.x default user profile; if you strip it # out the users will authenticate but the token endpoint will return an # "Account is not fully set up" error on the first login. USERS=(    "rwuser1:rw-group:Read:Write1"    "rwuser2:rw-group:Read:Write2"    "rouser1:ro-group:Read:Only1"    "rouser2:ro-group:Read:Only2"    "wouser1:wo-group:Write:Only1"    "wouser2:wo-group:Write:Only2"    "nauser1:no-access-group:No:Access1"    "nauser2:no-access-group:No:Access2" ) API="${KC_URL}/admin/realms" #---------------------------------------------------------------------- # Auth + helpers #---------------------------------------------------------------------- echo "==> Authenticating to Keycloak ${KC_URL} as ${KC_USER}" TOKEN=$(curl -sf -X POST "${KC_URL}/realms/master/protocol/openid-connect/token" \    -d "username=${KC_USER}" \    -d "password=${KC_PASS}" \    -d "grant_type=password" \    -d "client_id=admin-cli" | jq -r '.access_token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then    echo "ERROR: failed to obtain Keycloak admin token" >&2    exit 1 fi echo "    OK (token: ${TOKEN:0:24}...)" # api_get URL... # GET with auth + Accept JSON. Prints the body to stdout. api_get() {    curl -sk -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/json" "$@" } # api_write METHOD URL [BODY] # Sends a POST/PATCH/PUT, captures both body and HTTP status. On non-2xx, # prints the body and returns non-zero. On success, prints the response body # to stdout (may be empty for 201/204). api_write() {    local method=$1 url=$2 body=${3:-}    local tmp    tmp=$(mktemp)    local code    if [ -n "$body" ]; then        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}" \            -H "Content-Type: application/json" \            -d "$body")    else        code=$(curl -sk -o "$tmp" -w "%{http_code}" \            -X "$method" "$url" \            -H "Authorization: Bearer ${TOKEN}")    fi    if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then        echo "ERROR: ${method} ${url} returned HTTP ${code}" >&2        [ -n "$body" ] && echo "  Request body: $body" >&2        echo "  Response body:" >&2        cat "$tmp" >&2        echo >&2        rm -f "$tmp"        return 1    fi    cat "$tmp"    rm -f "$tmp" } #---------------------------------------------------------------------- # 1. Realm #---------------------------------------------------------------------- echo "==> Realm '${KC_REALM}'" realm_code=$(curl -sk -o /dev/null -w "%{http_code}" \    -H "Authorization: Bearer ${TOKEN}" \    "${API}/${KC_REALM}") if [ "$realm_code" = "404" ]; then    echo "    [realm] '${KC_REALM}' missing, POST"    body=$(jq -n \        --arg r "$KC_REALM" \        --arg fe "$KC_FRONTEND_URL" \        '{            realm: $r,            enabled: true,            sslRequired: "none",            attributes: { frontendUrl: $fe }        }')    api_write POST "${API}" "$body" >/dev/null elif [ "$realm_code" = "200" ]; then    echo "    [realm] '${KC_REALM}' exists" else    echo "ERROR: unexpected status ${realm_code} querying realm" >&2    exit 1 fi #---------------------------------------------------------------------- # 2. Base client #---------------------------------------------------------------------- echo "==> Base client '${KC_BASE_CLIENT}'" client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \    | jq -r '.[0].id // empty') if [ -z "$client_internal_id" ]; then    echo "    [client] '${KC_BASE_CLIENT}' missing, POST"    body=$(jq -n --arg c "$KC_BASE_CLIENT" '{        clientId: $c,        enabled: true,        publicClient: true,        directAccessGrantsEnabled: true,        standardFlowEnabled: true,        protocol: "openid-connect"    }')    api_write POST "${API}/${KC_REALM}/clients" "$body" >/dev/null    client_internal_id=$(api_get "${API}/${KC_REALM}/clients?clientId=${KC_BASE_CLIENT}" \        | jq -r '.[0].id // empty')    if [ -z "$client_internal_id" ]; then        echo "ERROR: created client but cannot resolve its internal id" >&2        exit 1    fi else    echo "    [client] '${KC_BASE_CLIENT}' exists" fi echo "          internal id=${client_internal_id}" #---------------------------------------------------------------------- # 3. Groups (native KC groups, no LDAP mapper) #---------------------------------------------------------------------- echo "==> Groups" declare -A GROUP_IDS for gname in "${GROUPS[@]}"; do    gid=$(api_get "${API}/${KC_REALM}/groups?search=${gname}&exact=true" \        | jq -r --arg n "$gname" 'map(select(.name==$n)) | .[0].id // empty')    if [ -z "$gid" ]; then        echo "    [group] '${gname}' missing, POST"        body=$(jq -n --arg n "$gname" '{name:$n}')        api_write POST "${API}/${KC_REALM}/groups" "$body" >/dev/null        gid=$(api_get "${API}/${KC_REALM}/groups?search=${gname}&exact=true" \            | jq -r --arg n "$gname" 'map(select(.name==$n)) | .[0].id // empty')        if [ -z "$gid" ]; then            echo "ERROR: created group '${gname}' but cannot resolve its id" >&2            exit 1        fi    else        echo "    [group] '${gname}' exists"    fi    GROUP_IDS[$gname]=$gid    echo "          id=${gid}" done #---------------------------------------------------------------------- # 4. Users (create + profile + password + group membership) #---------------------------------------------------------------------- echo "==> Users" for row in "${USERS[@]}"; do    IFS=':' read -r uname grp fname lname <<<"$row"    email="${uname}@example.com"    # 4a. Look up or create the user. Inline email/firstName/lastName/    # credentials are silently dropped by KC 26.x on POST, so we deliberately    # send only username + enabled here; the profile is patched in 4b.    uid=$(api_get "${API}/${KC_REALM}/users?username=${uname}&exact=true" \        | jq -r --arg n "$uname" 'map(select(.username==$n)) | .[0].id // empty')    if [ -z "$uid" ]; then        echo "    [user] '${uname}' missing, POST"        body=$(jq -n --arg u "$uname" '{username:$u, enabled:true}')        api_write POST "${API}/${KC_REALM}/users" "$body" >/dev/null        uid=$(api_get "${API}/${KC_REALM}/users?username=${uname}&exact=true" \            | jq -r --arg n "$uname" 'map(select(.username==$n)) | .[0].id // empty')        if [ -z "$uid" ]; then            echo "ERROR: created user '${uname}' but cannot resolve its id" >&2            exit 1        fi    else        echo "    [user] '${uname}' exists"    fi    # 4b. Set profile. Always PUT — idempotent and cheap.    profile_body=$(jq -n \        --arg e "$email" \        --arg f "$fname" \        --arg l "$lname" \        '{email:$e, emailVerified:true, firstName:$f, lastName:$l, enabled:true}')    api_write PUT "${API}/${KC_REALM}/users/${uid}" "$profile_body" >/dev/null    # 4c. Reset password. Always set — idempotent, picks up $PASSWORD changes.    pw_body=$(jq -n --arg p "$PASSWORD" \        '{type:"password", value:$p, temporary:false}')    api_write PUT "${API}/${KC_REALM}/users/${uid}/reset-password" "$pw_body" >/dev/null    # 4d. Group membership — PUT is idempotent (returns 204 even if already a    # member). Add to the single group this user belongs to; don't bother    # auditing for membership in *other* groups since the demo only writes    # one mapping per user.    gid=${GROUP_IDS[$grp]}    api_write PUT "${API}/${KC_REALM}/users/${uid}/groups/${gid}" "" >/dev/null    echo "          ${uname} -> ${grp} (uid=${uid:0:12}..., gid=${gid:0:12}...)" done #---------------------------------------------------------------------- # 5. 'groups' protocol mapper on the base client #---------------------------------------------------------------------- echo "==> 'groups' protocol mapper on '${KC_BASE_CLIENT}'" have_groups_mapper=$(api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq 'map(select(.name=="groups")) | length') if [ "$have_groups_mapper" -eq 0 ]; then    echo "    [mapper] 'groups' missing, POST"    body=$(jq -n '{        name: "groups",        protocol: "openid-connect",        protocolMapper: "oidc-group-membership-mapper",        config: {            "full.path": "false",            "id.token.claim": "true",            "access.token.claim": "true",            "claim.name": "groups",            "userinfo.token.claim": "true"        }    }')    api_write POST "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" "$body" >/dev/null else    echo "    [mapper] 'groups' exists" fi #---------------------------------------------------------------------- # Verification #---------------------------------------------------------------------- echo echo "==> Verification" echo "    Realm frontendUrl:" api_get "${API}/${KC_REALM}" | jq -r '"      " + (.attributes.frontendUrl // "(unset)")' echo "    Users in realm:" api_get "${API}/${KC_REALM}/users?max=20" | jq -r '.[] | "      " + .username + "  (" + (.email // "no-email") + ")"' echo "    Groups in realm:" api_get "${API}/${KC_REALM}/groups" | jq -r '.[] | "      " + .name' echo "    Membership:" for row in "${USERS[@]}"; do    IFS=':' read -r uname _ _ _ <<<"$row"    uid=$(api_get "${API}/${KC_REALM}/users?username=${uname}&exact=true" \        | jq -r --arg n "$uname" 'map(select(.username==$n)) | .[0].id // empty')    memb=$(api_get "${API}/${KC_REALM}/users/${uid}/groups" \        | jq -r 'map(.name) | join(",")')    echo "      ${uname} -> ${memb}" done echo "    Protocol mappers on '${KC_BASE_CLIENT}':" api_get "${API}/${KC_REALM}/clients/${client_internal_id}/protocol-mappers/models" \    | jq -r '.[] | "      " + .name + " (" + .protocolMapper + ")"' echo echo "Done. Next: ./configure-keycloak-clients.sh" ``` #### test-hidden-prefix.sh **test-hidden-prefix.sh** ```bash #!/bin/bash # test-hidden-prefix.sh — verify that bucket-policies/hidden-prefix-policy.json # successfully isolates the `hidden/` prefix to rwuser1. # # Pre-conditions: #   - The base demo is in place (run configure-keycloak-ldap.sh + #     configure-keycloak-clients.sh + create-iam.sh first) #   - bucket-policies/hidden-prefix-policy.json is attached to demo-bucket #     (this script does NOT apply the policy — the README walkthrough does) # # Expected matrix (10 cells): #   rwuser1 PUT  hidden/secret.txt          allow #   rwuser1 LIST s3://demo-bucket/hidden/   allow (sees secret.txt) #   rwuser1 GET  hidden/secret.txt          allow #   rwuser1 PUT  regular/file.txt           allow  (sanity) #   rwuser2 PUT  hidden/intrusion.txt       deny #   rwuser2 LIST s3://demo-bucket/hidden/   deny #   rwuser2 GET  hidden/secret.txt          deny #   rwuser2 PUT  regular/file.txt           allow  (sanity — rw still works outside hidden/) #   rwuser2 GET  regular/file.txt           allow  (sanity) #   rwuser2 LIST s3://demo-bucket/          allow  (root listing still works, #                                                   even though hidden/ appears #                                                   as CommonPrefix — see KB caveat) KC_URL="${KC_URL:-http://localhost:8080}" KC_REALM="${KC_REALM:-vast}" VAST_VIP="${VAST_VIP:?set VAST_VIP (an S3 data VIP on your tenant)}" TENANT_NAME="${TENANT_NAME:?set TENANT_NAME}" BUCKET="${BUCKET:-demo-bucket}" PASSWORD="${PASSWORD:?set PASSWORD in env (the LDAP demo user password)}" PASS=0 FAIL=0 record() {    if [ "$1" = "PASS" ]; then        PASS=$((PASS+1))        echo "  PASS — $2"    else        FAIL=$((FAIL+1))        echo "  FAIL — $2"    fi } assume_role() {    local username=$1 client_id=$2 role_name=$3    local role_arn="arn:vast::${TENANT_NAME}:role/${role_name}"    local id_token    id_token=$(curl -s -X POST "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \        -d "grant_type=password" -d "client_id=${client_id}" \        -d "username=${username}" -d "password=${PASSWORD}" \        -d "scope=openid" | jq -r '.id_token // empty')    if [ -z "$id_token" ]; then        echo "  ERROR: token request failed for ${username}/${client_id}" >&2        return 1    fi    local sts    sts=$(aws sts assume-role-with-web-identity \        --role-arn "$role_arn" \        --role-session-name "${username}-hp-session" \        --web-identity-token "$id_token" \        --endpoint-url "https://${VAST_VIP}" \        --no-verify-ssl 2>/dev/null)    local ak    ak=$(echo "$sts" | jq -r '.Credentials.AccessKeyId // empty')    if [ -z "$ak" ]; then        echo "  ERROR: STS failed for ${username}" >&2        return 1    fi    export AWS_ACCESS_KEY_ID=$ak    export AWS_SECRET_ACCESS_KEY=$(echo "$sts" | jq -r '.Credentials.SecretAccessKey')    export AWS_SESSION_TOKEN=$(echo "$sts" | jq -r '.Credentials.SessionToken') } clear_creds() {    unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } # attempt_put ; echoes "allow" or "deny" attempt_put() {    if aws s3 cp "$1" "s3://${BUCKET}/$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } # attempt_get ; echoes "allow" or "deny" attempt_get() {    if aws s3 cp "s3://${BUCKET}/$1" "$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } # attempt_ls ; echoes "allow" or "deny" attempt_ls() {    if aws s3 ls "s3://${BUCKET}/$1" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } assert_eq() {    local desc=$1 expected=$2 actual=$3    if [ "$expected" = "$actual" ]; then        record PASS "${desc}: ${actual}"    else        record FAIL "${desc}: expected ${expected}, got ${actual}"    fi } #--------------------------------------------------------------- # Phase 1 — rwuser1 (the privileged owner of hidden/) #--------------------------------------------------------------- echo echo "============================================" echo "  PHASE 1 — rwuser1 (owner of hidden/)" echo "============================================" assume_role rwuser1 vast-s3-rw rw-role || { echo "FAIL: cannot assume rw-role as rwuser1"; exit 1; } echo "secret content from rwuser1 $(date)" > /tmp/hp-secret.txt echo "regular content from rwuser1 $(date)" > /tmp/hp-regular.txt assert_eq "rwuser1 PUT  hidden/secret.txt"      allow "$(attempt_put /tmp/hp-secret.txt  hidden/secret.txt)" assert_eq "rwuser1 PUT  regular/file.txt"       allow "$(attempt_put /tmp/hp-regular.txt regular/file.txt)" assert_eq "rwuser1 LIST hidden/"                allow "$(attempt_ls  hidden/)" assert_eq "rwuser1 GET  hidden/secret.txt"      allow "$(attempt_get hidden/secret.txt /tmp/hp-fetched-1.txt)" clear_creds #--------------------------------------------------------------- # Phase 2 — rwuser2 (same role, but should be locked out of hidden/) #--------------------------------------------------------------- echo echo "============================================" echo "  PHASE 2 — rwuser2 (locked out of hidden/)" echo "============================================" assume_role rwuser2 vast-s3-rw rw-role || { echo "FAIL: cannot assume rw-role as rwuser2"; exit 1; } echo "intruder content from rwuser2 $(date)" > /tmp/hp-intrusion.txt assert_eq "rwuser2 PUT  hidden/intrusion.txt"   deny  "$(attempt_put /tmp/hp-intrusion.txt hidden/intrusion.txt)" assert_eq "rwuser2 LIST hidden/"                deny  "$(attempt_ls  hidden/)" assert_eq "rwuser2 GET  hidden/secret.txt"      deny  "$(attempt_get hidden/secret.txt /tmp/hp-fetched-2.txt)" assert_eq "rwuser2 PUT  regular/file2.txt"      allow "$(attempt_put /tmp/hp-intrusion.txt regular/file2.txt)" assert_eq "rwuser2 GET  regular/file.txt"       allow "$(attempt_get regular/file.txt /tmp/hp-fetched-3.txt)" assert_eq "rwuser2 LIST bucket root"            allow "$(attempt_ls  '')" clear_creds echo echo "============================================" echo "  Summary: ${PASS} passed, ${FAIL} failed" echo "  (10 expected)" echo "============================================" [ "$FAIL" -eq 0 ] ``` #### test-ldap-sts.sh **test-ldap-sts.sh** ```bash #!/bin/bash # test-ldap-sts.sh — End-to-end access-matrix test for the pure-LDAP # variant (LDAP owns users AND groups). # # For each LDAP user we: #   1. Request a JWT from the matching per-group Keycloak client (vast-s3-XX). #      Token issuance is gated server-side by a conditional-user-role check #      against the realm role mapped from the user's LDAP group. #   2. Trade the JWT for VAST temporary creds via STS, assuming the matching #      per-group IAM role (XX-role). The role's trust policy gates on #      :aud == vast-s3-XX, so the right JWT can only assume the #      right role. #   3. Run a PUT and a GET against demo-bucket. Identity policies attached to #      each role decide what the resulting session can actually do. # # Plus one negative test: a known-non-rw user (rouser1) is asked to fetch a # token from vast-s3-rw. This MUST fail at Keycloak (Deny Access fires inside # the per-client direct grant flow), proving group membership is load-bearing. # # Expected matrix: #   rwuser*  vast-s3-rw  rw-role  PUT allow  GET allow #   rouser*  vast-s3-ro  ro-role  PUT deny   GET allow #   wouser*  vast-s3-wo  wo-role  PUT allow  GET deny #   nauser*  vast-s3-na  na-role  PUT deny   GET deny #   rouser1  vast-s3-rw  (token request itself must FAIL) KC_URL="${KC_URL:-http://localhost:8080}" KC_REALM="${KC_REALM:-vast}" VAST_VIP="${VAST_VIP:?set VAST_VIP (an S3 data VIP on your tenant)}" TENANT_NAME="${TENANT_NAME:?set TENANT_NAME}" BUCKET="${BUCKET:-demo-bucket}" PASSWORD="${PASSWORD:?set PASSWORD in env (the LDAP demo user password)}" SEED_KEY="seed-from-rw.txt" PASS=0 FAIL=0 record() {    if [ "$1" = "PASS" ]; then        PASS=$((PASS+1))        echo "  RESULT: PASS — $2"    else        FAIL=$((FAIL+1))        echo "  RESULT: FAIL — $2"    fi } # get_token # echoes the id_token, or empty string if Keycloak refused. get_token() {    local username=$1 client_id=$2    curl -s -X POST "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \        -d "grant_type=password" \        -d "client_id=${client_id}" \        -d "username=${username}" \        -d "password=${PASSWORD}" \        -d "scope=openid" \        | jq -r '.id_token // empty' } # assume_role # Sets AWS_* env vars on success, returns 1 on failure. assume_role() {    local username=$1 client_id=$2 role_name=$3    local role_arn="arn:vast::${TENANT_NAME}:role/${role_name}"    local id_token    id_token=$(get_token "$username" "$client_id")    if [ -z "$id_token" ]; then        echo "  ERROR: Keycloak refused to issue a token for ${username} from ${client_id}" >&2        return 1    fi    echo "  Token claims:" >&2    echo "$id_token" | cut -d'.' -f2 | base64 -d 2>/dev/null \        | jq '{aud, email, preferred_username, groups}' >&2    local sts    # Discard stderr (urllib3 InsecureRequestWarning) — merging it with stdout    # poisons the JSON for jq.    sts=$(aws sts assume-role-with-web-identity \        --role-arn "$role_arn" \        --role-session-name "${username}-session" \        --web-identity-token "$id_token" \        --endpoint-url "https://${VAST_VIP}" \        --no-verify-ssl 2>/dev/null)    local ak    ak=$(echo "$sts" | jq -r '.Credentials.AccessKeyId // empty' 2>/dev/null)    if [ -z "$ak" ]; then        echo "  ERROR: STS assume-role failed for ${username}/${role_name}" >&2        echo "  raw stdout: $sts" >&2        return 1    fi    export AWS_ACCESS_KEY_ID=$ak    export AWS_SECRET_ACCESS_KEY=$(echo "$sts" | jq -r '.Credentials.SecretAccessKey')    export AWS_SESSION_TOKEN=$(echo "$sts" | jq -r '.Credentials.SessionToken')    echo "  STS AccessKeyId: $AWS_ACCESS_KEY_ID" >&2 } clear_creds() {    unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } attempt_put() {    if aws s3 cp "$1" "s3://${BUCKET}/$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } attempt_get() {    if aws s3 cp "s3://${BUCKET}/$1" "$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } # Generic per-user test. Args: USERNAME CLIENT_ID ROLE_NAME EXPECT_PUT EXPECT_GET test_user() {    local username=$1 client_id=$2 role_name=$3 expect_put=$4 expect_get=$5    echo    echo "============================================"    echo "  ${username} via ${client_id} → ${role_name}"    echo "  expect: PUT ${expect_put}, GET ${expect_get}"    echo "============================================"    assume_role "$username" "$client_id" "$role_name" || { record FAIL "STS"; return; }    local payload="/tmp/sts-test-${username}.txt"    echo "Hello from ${username} (ldap-users + ldap-groups variant) $(date)" > "$payload"    local put_result    put_result=$(attempt_put "$payload" "${username}.txt")    if [ "$put_result" = "$expect_put" ]; then        record PASS "PUT was ${put_result} as expected"    else        record FAIL "PUT was ${put_result}, expected ${expect_put}"    fi    if [ "$username" = "rwuser1" ]; then        echo "seed object placed by ${username} on $(date)" \          | aws s3 cp - "s3://${BUCKET}/${SEED_KEY}" \            --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1 \            && echo "  Seeded ${SEED_KEY}"    fi    local get_result    get_result=$(attempt_get "$SEED_KEY" "/tmp/sts-download-${username}.txt")    if [ "$get_result" = "$expect_get" ]; then        if [ "$get_result" = "allow" ]; then            record PASS "GET ${SEED_KEY} returned: $(cat /tmp/sts-download-${username}.txt)"        else            record PASS "GET was denied as expected"        fi    else        record FAIL "GET was ${get_result}, expected ${expect_get}"    fi    clear_creds } # rwuser1 must seed the test object first. test_user rwuser1 vast-s3-rw rw-role allow allow test_user rwuser2 vast-s3-rw rw-role allow allow test_user rouser1 vast-s3-ro ro-role deny  allow test_user rouser2 vast-s3-ro ro-role deny  allow test_user wouser1 vast-s3-wo wo-role allow deny test_user wouser2 vast-s3-wo wo-role allow deny test_user nauser1 vast-s3-na na-role deny  deny test_user nauser2 vast-s3-na na-role deny  deny # NEGATIVE TEST: rouser1 tries to get a token from vast-s3-rw. # Token issuance MUST fail because rouser1 lacks the vast-s3-rw realm role. echo echo "============================================" echo "  NEGATIVE: rouser1 → vast-s3-rw client" echo "  expect: Keycloak refuses to issue a token" echo "============================================" neg_token=$(get_token "rouser1" "vast-s3-rw") if [ -z "$neg_token" ]; then    record PASS "Keycloak refused (group is load-bearing)" else    record FAIL "Keycloak issued a token to rouser1 from vast-s3-rw — group gate broken" fi echo echo "============================================" echo "  Summary: ${PASS} passed, ${FAIL} failed" echo "  (17 expected — 16 access-matrix + 1 negative)" echo "============================================" [ "$FAIL" -eq 0 ] ``` #### test-local-sts.sh **test-local-sts.sh** ```bash #!/bin/bash # test-local-sts.sh — End-to-end test for the local-users Keycloak variant. # # For each Keycloak user we: #   1. Request a JWT from the matching per-group Keycloak client (vast-s3-XX). #      Token issuance is gated server-side by a conditional-user-role check #      against the realm role mapped from the user's Keycloak group. #   2. Trade the JWT for VAST temporary creds via STS, assuming the matching #      per-group IAM role (XX-role). The role's trust policy gates on #      :aud == vast-s3-XX, so the right JWT can only assume the #      right role. #   3. Run a PUT and a GET against demo-bucket. Identity policies attached to #      each role decide what the resulting session can actually do. # # Plus one negative test: a known-non-rw user (rouser1) is asked to fetch a # token from vast-s3-rw. This MUST fail at Keycloak (Deny Access fires inside # the per-client direct grant flow), proving group membership is load-bearing. # # Expected matrix: #   rwuser*  vast-s3-rw  rw-role  PUT allow  GET allow #   rouser*  vast-s3-ro  ro-role  PUT deny   GET allow #   wouser*  vast-s3-wo  wo-role  PUT allow  GET deny #   nauser*  vast-s3-na  na-role  PUT deny   GET deny #   rouser1  vast-s3-rw  (token request itself must FAIL) KC_URL="${KC_URL:-http://localhost:8080}" KC_REALM="${KC_REALM:-vast}" VAST_VIP="${VAST_VIP:?set VAST_VIP (S3 data VIP on your tenant)}" TENANT_NAME="${TENANT_NAME:?set TENANT_NAME}" BUCKET="${BUCKET:-demo-bucket}" PASSWORD="${PASSWORD:?set PASSWORD in env (the demo user password you set in configure-keycloak-local.sh)}" SEED_KEY="seed-from-rw.txt" PASS=0 FAIL=0 record() {    if [ "$1" = "PASS" ]; then        PASS=$((PASS+1))        echo "  RESULT: PASS — $2"    else        FAIL=$((FAIL+1))        echo "  RESULT: FAIL — $2"    fi } # get_token # echoes the id_token, or empty string if Keycloak refused. get_token() {    local username=$1 client_id=$2    curl -s -X POST "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \        -d "grant_type=password" \        -d "client_id=${client_id}" \        -d "username=${username}" \        -d "password=${PASSWORD}" \        -d "scope=openid" \        | jq -r '.id_token // empty' } # assume_role # Sets AWS_* env vars on success, returns 1 on failure. assume_role() {    local username=$1 client_id=$2 role_name=$3    local role_arn="arn:vast::${TENANT_NAME}:role/${role_name}"    local id_token    id_token=$(get_token "$username" "$client_id")    if [ -z "$id_token" ]; then        echo "  ERROR: Keycloak refused to issue a token for ${username} from ${client_id}" >&2        return 1    fi    echo "  Token claims:" >&2    echo "$id_token" | cut -d'.' -f2 | base64 -d 2>/dev/null \        | jq '{aud, email, preferred_username, groups}' >&2    local sts    # Discard stderr (urllib3 InsecureRequestWarning) — merging it with stdout    # poisons the JSON for jq.    sts=$(aws sts assume-role-with-web-identity \        --role-arn "$role_arn" \        --role-session-name "${username}-session" \        --web-identity-token "$id_token" \        --endpoint-url "https://${VAST_VIP}" \        --no-verify-ssl 2>/dev/null)    local ak    ak=$(echo "$sts" | jq -r '.Credentials.AccessKeyId // empty' 2>/dev/null)    if [ -z "$ak" ]; then        echo "  ERROR: STS assume-role failed for ${username}/${role_name}" >&2        echo "  raw stdout: $sts" >&2        return 1    fi    export AWS_ACCESS_KEY_ID=$ak    export AWS_SECRET_ACCESS_KEY=$(echo "$sts" | jq -r '.Credentials.SecretAccessKey')    export AWS_SESSION_TOKEN=$(echo "$sts" | jq -r '.Credentials.SessionToken')    echo "  STS AccessKeyId: $AWS_ACCESS_KEY_ID" >&2 } clear_creds() {    unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } attempt_put() {    if aws s3 cp "$1" "s3://${BUCKET}/$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } attempt_get() {    if aws s3 cp "s3://${BUCKET}/$1" "$2" \         --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1; then        echo allow    else        echo deny    fi } # Generic per-user test. Args: USERNAME CLIENT_ID ROLE_NAME EXPECT_PUT EXPECT_GET test_user() {    local username=$1 client_id=$2 role_name=$3 expect_put=$4 expect_get=$5    echo    echo "============================================"    echo "  ${username} via ${client_id} → ${role_name}"    echo "  expect: PUT ${expect_put}, GET ${expect_get}"    echo "============================================"    assume_role "$username" "$client_id" "$role_name" || { record FAIL "STS"; return; }    local payload="/tmp/sts-test-${username}.txt"    echo "Hello from ${username} (local-user variant) $(date)" > "$payload"    local put_result    put_result=$(attempt_put "$payload" "${username}.txt")    if [ "$put_result" = "$expect_put" ]; then        record PASS "PUT was ${put_result} as expected"    else        record FAIL "PUT was ${put_result}, expected ${expect_put}"    fi    if [ "$username" = "rwuser1" ]; then        echo "seed object placed by ${username} on $(date)" \          | aws s3 cp - "s3://${BUCKET}/${SEED_KEY}" \            --endpoint-url "https://${VAST_VIP}" --no-verify-ssl >/dev/null 2>&1 \            && echo "  Seeded ${SEED_KEY}"    fi    local get_result    get_result=$(attempt_get "$SEED_KEY" "/tmp/sts-download-${username}.txt")    if [ "$get_result" = "$expect_get" ]; then        if [ "$get_result" = "allow" ]; then            record PASS "GET ${SEED_KEY} returned: $(cat /tmp/sts-download-${username}.txt)"        else            record PASS "GET was denied as expected"        fi    else        record FAIL "GET was ${get_result}, expected ${expect_get}"    fi    clear_creds } # rwuser1 must seed the test object first. test_user rwuser1 vast-s3-rw rw-role allow allow test_user rwuser2 vast-s3-rw rw-role allow allow test_user rouser1 vast-s3-ro ro-role deny  allow test_user rouser2 vast-s3-ro ro-role deny  allow test_user wouser1 vast-s3-wo wo-role allow deny test_user wouser2 vast-s3-wo wo-role allow deny test_user nauser1 vast-s3-na na-role deny  deny test_user nauser2 vast-s3-na na-role deny  deny # NEGATIVE TEST: rouser1 tries to get a token from vast-s3-rw. # Token issuance MUST fail because rouser1 lacks the vast-s3-rw realm role. echo echo "============================================" echo "  NEGATIVE: rouser1 → vast-s3-rw client" echo "  expect: Keycloak refuses to issue a token" echo "============================================" neg_token=$(get_token "rouser1" "vast-s3-rw") if [ -z "$neg_token" ]; then    record PASS "Keycloak refused (group is load-bearing)" else    record FAIL "Keycloak issued a token to rouser1 from vast-s3-rw — group gate broken" fi echo echo "============================================" echo "  Summary: ${PASS} passed, ${FAIL} failed" echo "  (17 expected — 16 access-matrix + 1 negative)" echo "============================================" [ "$FAIL" -eq 0 ] ``` ## LDAP Files #### **ous.ldif** **ous.ldif** ```plaintext dn: ou=users,dc=vast,dc=local objectClass: organizationalUnit ou: users dn: ou=groups,dc=vast,dc=local objectClass: organizationalUnit ou: groups ``` #### **users.ldif** **users.ldif** ```plaintext dn: uid=rwuser1,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: rwuser1 cn: Read Write sn: User1 mail: rwuser1@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=rwuser2,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: rwuser2 cn: Read Write sn: User2 mail: rwuser2@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=rouser1,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: rouser1 cn: Read Only sn: User1 mail: rouser1@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=rouser2,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: rouser2 cn: Read Only sn: User2 mail: rouser2@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=wouser1,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: wouser1 cn: Write Only sn: User1 mail: wouser1@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=wouser2,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: wouser2 cn: Write Only sn: User2 mail: wouser2@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=nauser1,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: nauser1 cn: No Access sn: User1 mail: nauser1@example.com userPassword: CHANGEME_BEFORE_LOAD dn: uid=nauser2,ou=users,dc=vast,dc=local objectClass: inetOrgPerson uid: nauser2 cn: No Access sn: User2 mail: nauser2@example.com userPassword: CHANGEME_BEFORE_LOAD ``` #### groups.ldif **groups.ldif** ```plaintext dn: cn=rw-group,ou=groups,dc=vast,dc=local objectClass: groupOfNames cn: rw-group member: uid=rwuser1,ou=users,dc=vast,dc=local member: uid=rwuser2,ou=users,dc=vast,dc=local dn: cn=ro-group,ou=groups,dc=vast,dc=local objectClass: groupOfNames cn: ro-group member: uid=rouser1,ou=users,dc=vast,dc=local member: uid=rouser2,ou=users,dc=vast,dc=local dn: cn=wo-group,ou=groups,dc=vast,dc=local objectClass: groupOfNames cn: wo-group member: uid=wouser1,ou=users,dc=vast,dc=local member: uid=wouser2,ou=users,dc=vast,dc=local dn: cn=no-access-group,ou=groups,dc=vast,dc=local objectClass: groupOfNames cn: no-access-group member: uid=nauser1,ou=users,dc=vast,dc=local member: uid=nauser2,ou=users,dc=vast,dc=local ``` ## IAM Roles #### **na-identity-policy.json** **na-identity-policy.json** ```json {    "Version": "2012-10-17",    "Statement": [        {            "Sid": "ExplicitDenyEverything",            "Effect": "Deny",            "Action": "s3:*",            "Resource": "*"        }    ] } ``` #### **ro-identity-policy.json** **ro-identity-policy.json** ```json {    "Version": "2012-10-17",    "Statement": [        {            "Sid": "ReadOnlyAccessOnDemoBucket",            "Effect": "Allow",            "Action": [                "s3:GetObject",                "s3:GetObjectVersion",                "s3:ListBucket",                "s3:ListBucketVersions",                "s3:GetBucketLocation",                "s3:HeadBucket",                "s3:HeadObject"            ],            "Resource": [                "arn:aws:s3:::demo-bucket",                "arn:aws:s3:::demo-bucket/*"            ]        }    ] } ``` #### **rw-identity-policy.json** **rw-identity-policy.json** ```json {    "Version": "2012-10-17",    "Statement": [        {            "Sid": "FullAccessOnDemoBucket",            "Effect": "Allow",            "Action": "s3:*",            "Resource": [                "arn:aws:s3:::demo-bucket",                "arn:aws:s3:::demo-bucket/*"            ]        }    ] } ``` #### **wo-identity-policy.json** **wo-identity-policy.json** ```json {    "Version": "2012-10-17",    "Statement": [        {            "Sid": "WriteOnlyAccessOnDemoBucket",            "Effect": "Allow",            "Action": [                "s3:PutObject",                "s3:AbortMultipartUpload",                "s3:ListBucketMultipartUploads"            ],            "Resource": [                "arn:aws:s3:::demo-bucket",                "arn:aws:s3:::demo-bucket/*"            ]        }    ] } ``` ### Bucket-Policies #### hidden-prefix-policy.json **hidden-prefix-policy.json** ```json {    "Version": "2012-10-17",    "Statement": [        {            "Sid": "DenyListInsideHiddenPrefixForOthers",            "Effect": "Deny",            "Principal": "*",            "Action": [                "s3:ListBucket",                "s3:ListBucketVersions"            ],            "Resource": "arn:aws:s3:::demo-bucket",            "Condition": {                "StringLike": {                    "s3:prefix": "hidden/*"                },                "StringNotEquals": {                    "aws:username": "rwuser1@example.com"                }            }        },        {            "Sid": "DenyAccessToHiddenObjectsForOthers",            "Effect": "Deny",            "Principal": "*",            "Action": "s3:*",            "Resource": [                "arn:aws:s3:::demo-bucket/hidden",                "arn:aws:s3:::demo-bucket/hidden/*"            ],            "Condition": {                "StringNotEquals": {                    "aws:username": "rwuser1@example.com"                }            }        }    ] } ```