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: groupsusers.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_LOADgroups.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=localIAM 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"
}
}
}
]
}