STS Keycloak Files

Prev Next

Scripts

configure-keycloak-clients.sh

configure-keycloak-clients.sh

#!/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

#!/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

#!/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/<role>-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 <N>' 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/<realm>
#                     (no scheme, no leading slash, no trailing slash).
#                     Example:  <keycloak-host>: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://<keycloak-host>: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://<keycloak-host>: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/<issuer>" (the "oidc-provider/"
#     prefix is VAST's literal notation for an OIDC federated principal)
#   - Condition key is "<issuer>: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/<issuer>" },
#           "Action": "sts:AssumeRoleWithWebIdentity",
#           "Condition": {
#               "StringEquals": { "<issuer>:aud": "<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

#!/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
#      <issuer>: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 <username> <client_id>
# 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 <username> <client_id> <role_name>
# 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

#!/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

#!/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

#!/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 <local_file> <s3_key>; 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 <s3_key> <local_file>; 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 <s3_path>; 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

#!/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
#      <issuer>: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 <username> <client_id>
# 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 <username> <client_id> <role_name>
# 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

#!/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
#      <issuer>: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 <username> <client_id>
# 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 <username> <client_id> <role_name>
# 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

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

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

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

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ExplicitDenyEverything",
            "Effect": "Deny",
            "Action": "s3:*",
            "Resource": "*"
        }
    ]
}

ro-identity-policy.json

ro-identity-policy.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

{
    "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

{
    "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

{
    "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"
                }
            }
        }
    ]
}