KB.md
A reproducible demo showing how to give S3 users on a VAST cluster temporary credentials scoped by LDAP group membership, where the LDAP group is the load-bearing piece for access control. Moving a user between LDAP groups causes their effective S3 access to change immediately on the next token request.
What this demo proves
A customer running OpenLDAP (or any LDAP) wants to:
Authenticate S3 users against their existing LDAP directory.
Have S3 access automatically scoped by LDAP group membership — no per-user policy bookkeeping in VAST.
Use temporary S3 credentials (STS), not long-lived access keys, so credentials expire, and revocation is just a group membership change.
This demo wires up four worked examples:
LDAP group | S3 permission on |
|---|---|
| Full read/write |
| Read-only |
| Write-only (PutObject, etc; no GetObject) |
| Explicit deny — proves the gate isn't trivially open |
It also adds a fifth optional example: a hidden prefix inside demo-bucket that only one specific user (rwuser1) can read or write, even though other rw users have otherwise identical access.
End-to-end test result: 17 of 17 access assertions pass, plus a negative test that proves Keycloak refuses to issue a token to a user who is not a member of the matching LDAP group.
Vocabulary
A few terms before we go further. If you already know what OIDC, JWT claims, and IAM trust policies are, skip ahead.
OpenID Connect (OIDC) is an internet standard for "log in with…" flows, built on top of OAuth 2.0. An OIDC identity provider (IdP) like Keycloak holds the user database, runs the login screen, and hands back a signed token after a successful login.
That token is a JSON Web Token (JWT) — a short, signed JSON document. A JWT carries claims: small key-value facts about the holder, signed by the IdP's private key. A relying party can verify a JWT by checking its signature against the IdP's public key, which the IdP publishes over HTTPS at a JWKS endpoint.
Two claims matter for this demo. The iss claim names the IdP that minted the token (always the realm's issuer URL — VAST uses it to fetch the JWKS). The aud claim says which audience the token was minted for — usually the client_id of the OIDC client that requested it. We gate VAST role assumption on aud, which is why every per-group Keycloak client in this demo issues tokens with a distinct aud value.
VAST exposes a Security Token Service (STS) that mirrors the AWS STS API. The relevant call is AssumeRoleWithWebIdentity: hand it a JWT and the ARN of an IAM role, and if the role's trust policy allows it, you get back temporary S3 credentials (AccessKeyId, SecretAccessKey, SessionToken) that expire after about an hour.
A VAST IAM role has two policy attachments. The trust policy says who is allowed to assume this role — its conditions are evaluated against the incoming JWT's claims. The identity policy says what S3 actions the resulting session is allowed to perform. Both are JSON documents in the AWS IAM policy dialect; both support Allow and Deny statements; Deny always wins. A bucket policy is a third JSON document attached directly to a bucket. It applies to every caller regardless of which role they assumed, and is the right place for cross-cutting rules — the hidden-prefix bonus uses one.
With those terms in hand, here's how the pieces fit together.
Architecture
+-------------------+
| OpenLDAP | <-- single source of truth
| (users, groups) | for users and groups
+---------+---------+
|
| LDAP federation
| (group sync)
v
+-------------------+
| Keycloak | <-- OIDC IdP
| realm: vast |
| |
| per-group clients:|
| vast-s3-rw |
| vast-s3-ro | <-- each client refuses
| vast-s3-wo | to issue a token unless
| vast-s3-na | the user has the
+---------+---------+ matching realm role
| (mapped from LDAP group)
| JWT
| aud=vast-s3-XX
v
+-------------------+
| AWS CLI / | <-- client passes JWT to
| boto3 / app | VAST STS endpoint
+---------+---------+
|
| AssumeRoleWithWebIdentity
v
+-------------------+
| VAST Cluster |
| (your tenant) |
| |
| IAM roles: |
| rw-role <-- aud=vast-s3-rw
| ro-role <-- aud=vast-s3-ro
| wo-role <-- aud=vast-s3-wo
| na-role <-- aud=vast-s3-na
| | <-- trust policy gates on
| identity policies:| JWT aud claim. STS issues
| rw-policy | temporary creds tied to
| ro-policy | the matched role.
| wo-policy |
| na-policy |
+---------+---------+
|
| temporary access key + session token
v
+-------------------+
| S3 endpoint |
| demo-bucket | <-- identity policy on the
| | role decides what S3
| (optional) | operations succeed
| bucket policy |
| for hidden/ | <-- optional bucket-policy
| prefix | overlay for cross-cutting
+-------------------+ rules (e.g. hidden prefix)The cleanest way to think about responsibility:
Layer | Decides |
|---|---|
OpenLDAP | Who the user is and which group(s) they belong to. |
Keycloak | Whether the user can get a token from a particular client at all. |
VAST IAM | Which role a JWT is allowed to assume, based on the JWT |
Identity policy | What S3 actions the resulting session can perform. |
Bucket policy (optional) | Cross-cutting rules visible to every role (carve-outs, hidden prefixes, etc.) |
What's in each component
OpenLDAP
A vanilla osixia/openldap container exposes port 389. The directory has two organizational units:
ou=users,dc=vast,dc=local— eightinetOrgPersonusers (rwuser1,rwuser2,rouser1,rouser2,wouser1,wouser2,nauser1,nauser2). Each hasuid,cn,sn,mail, anduserPassword.ou=groups,dc=vast,dc=local— fourgroupOfNamesgroups (rw-group,ro-group,wo-group,no-access-group). Each holds twomemberDN values pointing back intoou=users.
Bind DN for the demo: cn=admin,dc=vast,dc=local. The bind credential is whatever you set as LDAP_PASS (and pass into the OpenLDAP container as its LDAP_ADMIN_PASSWORD env var when you start it).
The three LDIF files in ldap-files define this structure. They are loaded into the container with ldapadd at bootstrap time.
There is no special schema required — only stock inetOrgPerson and groupOfNames from the standard OpenLDAP cosine and inetorgperson schemas.
Keycloak
Keycloak runs in its own container exposing 8080. The realm is named vast. There are two configuration phases:
Phase 1 — LDAP federation and group sync (script: configure-keycloak-ldap.sh)
Creates the
vastrealm.Creates a User Federation provider of type
ldappointing at the OpenLDAP container with the demo bind credentials.editModeisREAD_ONLYandimportEnabledistrue, so Keycloak imports users on first login and keeps them in sync with LDAP.Adds a
group-ldap-mapperso the LDAPgroupOfNamesgroups are imported as Keycloak groups under/rw-group,/ro-group, etc., and group membership is recomputed on each login.Triggers an initial full sync.
Creates one base client (
vast-s3, public, direct grants enabled) used by tooling that doesn't care about per-group restriction. The per-group clients are added in Phase 2.
After this phase, an LDAP user can authenticate against Keycloak with username + password (using the password grant against any client) and gets back a JWT containing a groups claim with their LDAP group names.
Phase 2 — Per-group clients with conditional flow (script: configure-keycloak-clients.sh)
For each of the four LDAP groups, the script creates a complete chain of objects so that token issuance is gated server-side by group membership. The chain for rw-group looks like this; the other three are identical substitutions:
Realm role
vast-s3-rwis created in thevastrealm.Group role mapping: the Keycloak group
/rw-group(which is already kept in sync from the LDAPrw-group) is given thevast-s3-rwrealm role. Any LDAP user who joinsrw-grouptherefore inherits thevast-s3-rwrealm role on their next login.Client
vast-s3-rwis created — public, direct grants enabled, OpenID Connect protocol. Agroupsprotocol mapper is attached so the issued JWT carries the user's group memberships in agroupsclaim (this is informational only; the actual gate is the trust policy on the matching VAST role, which checksaud, notgroups).Authentication flow named
vast-s3-rw direct grantis created by copying Keycloak's built-indirect grantflow.A conditional sub-flow named
vast-s3-rw group checkis added at the end of that flow withrequirement: CONDITIONAL.Inside the sub-flow, two executions are added in order:
conditional-user-role(REQUIRED), configured withcondUserRole=vast-s3-rwandnegate=true.deny-access-authenticator(REQUIRED).
The new flow is bound to the client as the
direct_grantoverride viaclients.<id>.authenticationFlowBindingOverrides.
How the conditional flow gates token issuance:
A user requests a token against the
vast-s3-rwclient via the password grant.Keycloak runs the bound
vast-s3-rw direct grantflow.It validates username and password as normal.
It enters the
vast-s3-rw group checkconditional sub-flow.The conditional condition is
conditional-user-rolewithnegate=trueandcondUserRole=vast-s3-rw. This evaluates to true when the user does not have the role.If the user has
vast-s3-rw(because they're inrw-groupin LDAP): the negated check returns false, the conditional sub-flow is skipped, the parent flow completes successfully, and a JWT is issued.If the user lacks
vast-s3-rw(they're in some other group): the negated check returns true, the conditional sub-flow runs, thedeny-access-authenticatorexecution fires, the password grant returns an error, and no token is issued.
The same chain exists for vast-s3-ro, vast-s3-wo, vast-s3-na.
The JWTs that do get issued by these clients have aud set to the clientId (e.g. aud=vast-s3-rw). That aud is what VAST will gate role assumption on.
VAST (VMS configuration)
All VAST objects live inside the tenant named by TENANT_NAME. The tenant must already have an OIDC provider configured against your Keycloak realm's issuer URL (e.g. http://<keycloak-host>:8080/realms/vast). Any pre-existing IAM roles, AD / local providers, or other tenant-level config are left untouched by this demo.
The demo creates four identity policies and four IAM roles in this tenant via the VMS REST API (script: create-iam.sh).
Identity policies define what S3 operations are permitted. They are JSON documents stored at iam-roles/{rw,ro,wo,na}-identity-policy.json in this repo and uploaded to the cluster as s3policies via the API.
Policy name | Effect | Actions | Resources |
|---|---|---|---|
| Allow |
|
|
| Allow |
|
|
| Allow |
|
|
| Deny |
|
|
IAM roles wrap an identity policy with a trust policy that says which JWTs are allowed to assume the role. The four trust policies in this demo are structurally identical except for one field — the expected aud value — so create-iam.sh doesn't ship them as separate JSON files. Instead, its build_trust_policy() function renders the trust policy at runtime from the OIDC issuer URL it derives from KC_FRONTEND_URL (or KC_URL) and the matching aud value. The shape it stamps out is:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "oidc-provider/<issuer>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"<issuer>:aud": "<aud>"
}
}
}
]
}<issuer> is the realm's authority + path, no scheme — e.g. <keycloak-host>:8080/realms/vast. <aud> is one of vast-s3-rw, vast-s3-ro, vast-s3-wo, vast-s3-na.
The condition key <issuer>:aud reads the JWT's aud claim. Each role's trust policy hardcodes a different expected aud value, so a JWT issued by the vast-s3-rw client can only be used to assume rw-role, and so on. Because <issuer> is derived from a single env var the user already sets in Step 0, the iss claim Keycloak embeds in JWTs and the issuer in the trust policy stay in lockstep automatically — no editing four files when you move to a different Keycloak host.
Because Keycloak refuses to issue a vast-s3-rw token unless the user is in rw-group, the chain is complete: only members of rw-group can ever end up with credentials backed by rw-role, and rw-role is the only role whose identity policy grants full access to demo-bucket.
When the create-iam.sh script POSTs an IAM role to the VMS API, it includes a write-only s3_policies: [<policy_id>] field; VMS persists the relationship by adding the role name to the users field of the referenced policy. Both views are consistent — you can ask "which roles use this policy?" by inspecting the policy, and the verification block at the end of create-iam.sh does exactly that.
Bucket policy (optional)
The base demo does not require a bucket policy. The four roles, plus their identity policies, are sufficient to satisfy the per-group access matrix.
A bucket policy applies only to the hidden-prefix bonus described later. Bucket policies are useful when you want a rule that applies regardless of which role the caller assumed — for example, "these specific objects can only be touched by this specific user, no matter what role they hold". They are layered on top of identity-policy grants, with Deny statements winning over Allows.
How a request flows end-to-end
Concrete example: rwuser1 runs aws s3 cp file.txt s3://demo-bucket/file.txt.
rwuser1(or rather, the test script on their behalf) hits Keycloak with the password grant against thevast-s3-rwclient:POST /realms/vast/protocol/openid-connect/token grant_type=password client_id=vast-s3-rw username=rwuser1 password=<the LDAP demo-user password, $PASSWORD> scope=openidKeycloak runs the
vast-s3-rw direct grantflow:Validates username + password against the LDAP-federated user store.
Enters the conditional
vast-s3-rw group checksub-flow.conditional-user-role(vast-s3-rw, negate=true)evaluates: does the user have the realm rolevast-s3-rw? Yes (because the/rw-groupKeycloak group has thevast-s3-rwrole mapped to it, andrwuser1is in/rw-group). So the negated condition is false, and the entire sub-flow is skipped.Flow exits success.
Keycloak issues a JWT. The
audclaim isvast-s3-rw. Thegroupsclaim contains["rw-group"]. Theemailclaim isrwuser1@example.com.The test script calls
aws sts assume-role-with-web-identityagainst the VAST cluster STS endpoint, passing this JWT and the role ARNarn:vast::<tenant>:role/rw-role.VAST validates the JWT's signature against the OIDC provider's JWKS (cached from the issuer URL in the JWT's
issclaim).VAST evaluates
rw-role's trust policy. The conditionStringEqualson<issuer>:aud == vast-s3-rwmatches. STS allows the role to be assumed.VAST issues temporary credentials. The session is associated with
rw-rolefor permission purposes; theaws:usernamecondition key (when used in bucket policies) resolves to the underlying JWT user's email,rwuser1@example.com.The test script exports
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENfrom those credentials and runsaws s3 cp.The S3 endpoint authenticates the SigV4 request, looks up the session, and evaluates
rw-role's identity policy:s3:*ondemo-bucket/*is Allow. There is no bucket policy denying it. The PUT succeeds.
For an rouser1 request to the same vast-s3-rw client:
Same Keycloak password grant, but
client_id=vast-s3-rw.Flow runs username + password validation: success.
Enters the conditional sub-flow.
conditional-user-role(vast-s3-rw, negate=true)evaluates: doesrouser1have rolevast-s3-rw? No (the/ro-groupgroup is mapped tovast-s3-ro, notvast-s3-rw). So the negated condition is true, and the sub-flow runs.deny-access-authenticatorexecutes inside the sub-flow.The password grant returns HTTP 401 with an error response. No JWT is issued.
rouser1simply cannot get a token from thevast-s3-rwclient, full stop. There is no client-side workaround — the gate is enforced at Keycloak before the JWT is signed.
For an nauser1 request to the vast-s3-na client (their matching client):
Same flow.
nauser1is inno-access-group, which is mapped tovast-s3-na. They successfully obtain a JWT withaud=vast-s3-na.They assume
na-role(its trust policy gates onaud == vast-s3-na).They run an S3 PUT.
na-role's identity policy is an explicitDenyons3:*on*. The request is rejected.
The negative case is what proves the gate is real: a user can get a token, can assume a role, but the role they end up with is one whose identity policy explicitly denies everything. Membership in no-access-group is therefore equivalent to "has an account, has no S3 access".
Set this up yourself
Prerequisites
A VAST cluster with:
A tenant configured to your liking. Set
TENANT_NAMEto its name.An OIDC provider on that tenant pointing at your Keycloak realm's issuer URL.
At least one S3-enabled view exposing a bucket named whatever you set
BUCKETto (the walkthrough usesdemo-bucket).Admin credentials for the VMS REST API.
At least one S3 access key for the bucket owner so you can apply bucket policies later.
A Linux host that can reach both Keycloak (
http://<host>:8080) and the VAST cluster's S3 endpoints. The host needs:bash,curl,jq,awsCLI v2,python3.podman(ordocker) if you want to run OpenLDAP and Keycloak on the same host as the demo (recommended).
The demo runs OpenLDAP and Keycloak as Podman containers on the same host as the test runner.
Step 0 — Set environment variables
Every script in this walkthrough reads its config from the environment. Set these once, and the rest of the steps just work; if you skip this step, the scripts will refuse to run with placeholder values.
# VMS — used by create-iam.sh (TENANT_NAME also used by tests)
export VMS_HOST=vms.example.com # <-- your VMS host
export VMS_USER=admin
export VMS_PASS='your-vms-admin-password'
export TENANT_NAME=your-tenant-name # <-- your tenant name
# Keycloak + LDAP — used by both configure-keycloak-*.sh scripts
# (KC_URL also used by tests)
export KC_URL=http://localhost:8080
export KC_USER=admin
export KC_PASS='your-keycloak-admin-password'
export KC_REALM=vast
export LDAP_PASS='your-openldap-admin-password'
# Tests — used by test-*.sh (also reuse KC_URL and TENANT_NAME above)
export VAST_VIP=172.200.201.8
export BUCKET=demo-bucket
export PASSWORD='your-demo-user-password' # the LDAP demo-user passwordIf Keycloak runs in a container without --network=host, also set LDAP_URL so it can reach LDAP from inside its container:
export LDAP_URL='ldap://192.168.1.10:389' # your host's reachable IPIf VAST sees Keycloak at a different URL than this script does (reverse proxy, NAT, or a separate management network), set the issuer URL that VAST will embed in JWTs. Both configure-keycloak-ldap.sh and create-iam.sh read this same variable, so the issuer in the JWT and the issuer in the trust policies stay in lockstep:
export KC_FRONTEND_URL='http://keycloak.lab.example.com:8080'Step 1 — Run OpenLDAP and Keycloak
The container env-var names below (LDAP_ADMIN_PASSWORD, KEYCLOAK_ADMIN_PASSWORD) are what the upstream images expect; we just feed our ${LDAP_PASS} and ${KC_PASS} into them.
sudo podman run -d --name openldap \
-p 389:389 -p 636:636 \
-e LDAP_ORGANISATION="VAST Demo" \
-e LDAP_DOMAIN="vast.local" \
-e LDAP_ADMIN_PASSWORD="${LDAP_PASS}" \
docker.io/osixia/openldap:1.5.0
sudo podman run -d --name keycloak \
-p 8080:8080 -p 8443:8443 -p 9000:9000 \
-e KEYCLOAK_ADMIN="${KC_USER}" \
-e KEYCLOAK_ADMIN_PASSWORD="${KC_PASS}" \
quay.io/keycloak/keycloak:26.5.6 \
start-dev --health-enabled=trueWait until both containers are healthy (sudo podman ps shows Up X seconds).
Step 2 — Edit users.ldif, then load LDAP
users.ldif ships with each user's password as the literal string CHANGEME_BEFORE_LOAD. You must substitute it before loading, and the value must equal whatever you exported as ${PASSWORD} in Step 0 (otherwise the test scripts won't be able to log in as the demo users):
sed -i "s/CHANGEME_BEFORE_LOAD/${PASSWORD}/g" ldap-files/users.ldifThen load the three LDIFs in order — OUs first (the parents), then users, then groups:
ldapadd -x -H ldap://localhost:389 \
-D "cn=admin,dc=vast,dc=local" -w "${LDAP_PASS}" \
-f ldap-files/ous.ldif
ldapadd -x -H ldap://localhost:389 \
-D "cn=admin,dc=vast,dc=local" -w "${LDAP_PASS}" \
-f ldap-files/users.ldif
ldapadd -x -H ldap://localhost:389 \
-D "cn=admin,dc=vast,dc=local" -w "${LDAP_PASS}" \
-f ldap-files/groups.ldifVerify with ldapsearch:
ldapsearch -x -H ldap://localhost:389 \
-D "cn=admin,dc=vast,dc=local" -w "${LDAP_PASS}" \
-b "dc=vast,dc=local" "(uid=rwuser1)" dn uid mailStep 3 — Configure Keycloak realm + LDAP federation + base client
./configure-keycloak-ldap.shThis creates the vast realm, the vast-s3 base client, the LDAP user federation, the group LDAP mapper, triggers a full LDAP sync, and adds a groups protocol mapper to the base client. The script reads KC_PASS, LDAP_PASS, and the optional KC_* / LDAP_URL overrides from the environment you set in Step 0.
It is idempotent: each step looks up existing state and either skips or PATCHes. Safe to re-run.
After this, you can sanity-check the OIDC chain end-to-end:
ID_TOKEN=$(curl -s -X POST "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \
-d "grant_type=password" -d "client_id=vast-s3" \
-d "username=rwuser1" -d "password=${PASSWORD}" \
-d "scope=openid" | jq -r '.id_token')
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, email, groups}'You should see groups: ["rw-group"] in the output.
Step 4 — Configure per-group Keycloak clients
./configure-keycloak-clients.shThis is the Phase 2 work described above: 4 realm roles, 4 group→role mappings, 4 clients, 4 conditional flows, 4 flow bindings. The script prints a summary of each client at the end, including its authenticationFlowBindingOverrides. It reads the same KC_* env vars from Step 0 and is also idempotent.
Step 5 — Configure VAST IAM via VMS REST
./create-iam.shWhat the script does, call by call:
Auth —
POST /api/token/withVMS_USER/VMS_PASSas a JSON body. Returns a bearer token used for every subsequent call.Tenant lookup —
GET /api/tenants/?name=${TENANT_NAME}resolves the tenant's numeric id.Four identity policies (
rw-policy,ro-policy,wo-policy,na-policy) — for each: aGET /api/s3policies/?name=<n>&tenant_id=<tid>lookup, then eitherPOST /api/s3policies/(if missing) orPATCH /api/s3policies/<id>/(if present). The policy body is read verbatim fromiam-roles/<name>-identity-policy.json.Four IAM roles (
rw-role,ro-role,wo-role,na-role) — for each: aGET /api/iamroles/?name=<n>lookup followed byPOST /api/iamroles/orPATCH /api/iamroles/<id>/. The role body carries the in-script-generated trust policy, the matching identity policy's id, andmax_session_duration(fromMAX_SESSION_DURATION, default900).Verification —
GET /api/iamroles/andGET /api/s3policies/?tenant_id=<tid>filtered to the four demo names, printed as a final summary.
The trust policies are generated in-script by build_trust_policy(), which picks the OIDC issuer to bake in in this order:
OIDC_ISSUERif you set it directly (e.g.<keycloak-host>:8080/realms/vast)Otherwise, derived from
KC_FRONTEND_URL+KC_REALMOtherwise, fall back to
KC_URL+KC_REALM
The first line of output is the issuer the script ultimately used, so a misconfiguration is immediately obvious.
The script is idempotent: re-running it PATCHes existing policies and roles in place. If you re-run after changing KC_FRONTEND_URL or MAX_SESSION_DURATION, the affected field on every role is rewritten.
Step 6 — Verify the access matrix
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
./test-ldap-sts.shThe script tests every (user, role) pair against demo-bucket, expecting the matrix in the table at the top of this article. It also runs one negative test: rouser1 attempting to get a token from the vast-s3-rw client. That request must be refused at the Keycloak layer.
Expected output:
Summary: 17 passed, 0 failed
(17 expected — 16 access-matrix + 1 negative)If anything fails, check the troubleshooting section below.
Bonus: hidden prefix for one specific user
The base demo proves group-based access. The hidden-prefix scenario proves you can layer per-user scoping on top of that, without changing the underlying role architecture.
Goal: rwuser1 should be the only user who can read, write, or list inside s3://demo-bucket/hidden/. rwuser2 (in the same rw-group, assuming the same rw-role, with the same identity-policy grants) should be denied.
This is done with a bucket policy that adds two Deny statements. Bucket-policy Deny overrides identity-policy Allow, so even though rw-role grants s3:* on demo-bucket, the targeted Denys carve out the hidden/ prefix.
bucket-policies/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"}
}
}
]
}Why this works:
Principal: "*"matches every caller, including STS sessions.aws:usernamefor an STS session resolves to the underlying JWT user's email —rwuser1@example.comfor rwuser1,rwuser2@example.comfor rwuser2, etc. (This is empirical VAST behavior; it works the same way for all OIDC STS callers.)StringNotEqualsonaws:usernameflips the test: the Deny applies to everyone except rwuser1.The first statement also requires
s3:prefixto start withhidden/, so it only fires on listings that target the hidden prefix.The second statement applies to any
s3:*action whose resource matchesdemo-bucket/hiddenor anything under it — covering get, put, delete, head, multipart, etc.
Apply it as the bucket owner (you'll need an S3 access key with policy write permission on the bucket):
export AWS_ACCESS_KEY_ID=<bucket-owner-key>
export AWS_SECRET_ACCESS_KEY=<bucket-owner-secret>
unset AWS_SESSION_TOKEN
aws s3api put-bucket-policy \
--bucket demo-bucket \
--policy file://bucket-policies/hidden-prefix-policy.json \
--endpoint-url "https://${VAST_VIP}" \
--no-verify-sslThen run the focused test:
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
./test-hidden-prefix.shExpected:
Summary: 10 passed, 0 failed
(10 expected)Importantly, after applying the hidden-prefix bucket policy, the base 17-cell test (test-ldap-sts.sh) still passes 17/17. The Deny statements are scoped narrowly enough that they don't disturb normal group-based access.
Two design patterns for prefix isolation
The bucket policy above is a Deny overlay applied on top of an identity policy that already grants broad access. It's the simplest pattern: the role's identity policy says "you can do anything in demo-bucket", and the bucket policy says "...except inside hidden/, and only for that one user".
There is a second, more rigorous pattern worth knowing about, which the VAST documentation page on bucket policy variables hints at. Instead of overlaying Denys, you make the bucket policy itself the sole grant for ListBucket, and condition it tightly on s3:prefix:
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::demo-bucket",
"Condition": {
"StringLike": {"s3:prefix": "hidden/*"},
"StringEquals": {"aws:username": "rwuser1@example.com"}
}
}With only this Allow (and no broader ListBucket grant from any identity policy), a request like aws s3 ls s3://demo-bucket/ arrives at the policy engine with s3:prefix="". The empty prefix doesn't match hidden/*, so the StringLike fails, the condition fails, the Allow doesn't grant, and there's no other Allow either — so the entire ListBucket request is denied. The caller doesn't even receive an empty response with CommonPrefixes; they get an AccessDenied error and see nothing about the existence of the hidden/ prefix.
Likewise, VAST documents support for the variable ${username} in Resource and Condition entries, which lets you write a single policy that auto-scopes to whoever is calling:
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
"Resource": [
"arn:aws:s3:::demo-bucket",
"arn:aws:s3:::demo-bucket/${username}/*"
],
"Condition": {
"StringLike": {"s3:prefix": "${username}/*"}
}
}Each user automatically sees only their own prefix — no per-user policy statements needed.
Trade-off: the Allow-scoping patterns are stricter, but they break clients that do an empty-prefix ListBucket as part of normal browsing (some AWS CLI flows, some boto3 patterns). If you control the clients, or the use case is "API access only", the strict pattern is the right default. If you have humans browsing with off-the-shelf S3 GUIs, the Deny-overlay pattern is more forgiving — at the cost of leaking the existence of the hidden prefix as a CommonPrefix entry in root listings.
One open caveat
The base hidden-prefix policy in this demo (the Deny overlay) does not hide the existence of hidden/ as a CommonPrefix in root listings. A non-rwuser doing aws s3 ls s3://demo-bucket/ will see hidden/ appear, even though they cannot list inside it. Switch to the Allow-scoping pattern above to also hide the prefix's existence.
Reference
Files and Scripts index
File | What it is |
|---|---|
LDIF for the 8 users | |
LDIF for the 4 groups | |
One-time Phase 1 Keycloak setup (realm, federation, sync) | |
Idempotent Phase 2: per-group clients with conditional flows | |
Identity policy: full S3 on | |
Identity policy: read-only on | |
Identity policy: write-only on | |
Identity policy: explicit Deny on everything | |
Idempotent VMS API: upserts the 4 policies + 4 roles. Trust policies are generated in-script ( | |
Optional Deny overlay carving out | |
The 17-cell access matrix verification | |
The 10-cell hidden-prefix verification |
VAST IAM trust policy condition keys
The JWT claim names that work as trust-policy condition keys (using the <issuer>:<claim> form, e.g. <keycloak-host>:8080/realms/vast:aud) are:
aud— used by this demo for role gatingsub— JWT subject (Keycloak user UUID)email— user emailazp— authorized party (typically the same as the client_id)
The demo uses aud because it's the cleanest way to project group identity onto one of these four: one Keycloak client per group, each issuing tokens with a distinct aud, one trust policy per role gating on that value.
A parser quirk worth noting: you cannot mix operator variants from the same family in one Condition block (e.g. both StringEquals and ForAnyValue:StringEquals). Use one operator family per Condition.
Bucket policy condition keys used in this demo
s3:prefix— forListBucket/ListBucketVersions. Compared withStringLike/StringEquals. Used in the hidden-prefix demo.aws:username— resolves for STS sessions to the underlying JWT user's email (e.g.rwuser1@example.com). Used in the hidden-prefix demo to scope aDenyto a specific user.
The bucket policy's Principal: "*" is the form that works for all callers, including STS sessions. (VAST also accepts native {"User": "name@domain"} and {"Group": "name@domain"} Principal forms, but those resolve against an LDAP/AD provider attached to the tenant — they do not resolve OIDC STS callers via the tenant's OIDC provider. For the OIDC STS use case, scope by aws:username instead of by Principal.)
Credential lifetimes
Two independent clocks run on every STS call:
JWT lifetime — set by Keycloak at the realm level (Realm Settings → Tokens → Access Token Lifespan; default 5 minutes). The JWT only needs to live long enough to complete the
AssumeRoleWithWebIdentitycall — once VAST has issued STS credentials it stops caring about the original JWT.STS credential lifetime — set by VAST at the role level via the
max_session_durationfield on each IAM role. This governs how long the returnedAccessKeyId/SecretAccessKey/SessionTokenremain valid. VAST enforces a floor of 900 seconds (15 minutes) and defaults to129600seconds (36 hours) when the field is omitted.
The client can optionally request a shorter-than-max lifetime via --duration-seconds on aws sts assume-role-with-web-identity (or DurationSeconds on the SDK call). Requests above the role's max_session_duration are rejected; requests at or below it return creds with that lifetime. If the client doesn't pass the flag, AWS/VAST default to 3600 s (1 hour) — subject to the role cap.
This demo pins max_session_duration to 900 seconds (the floor — 15 minutes), so a leaked session has the smallest possible blast radius. create-iam.sh reads this from the MAX_SESSION_DURATION env var, defaulting to 900. Raise it for workflows that can't re-auth mid-run:
export MAX_SESSION_DURATION=3600 # 1 hour
./create-iam.shCheck the actual expiry on any issued credential by reading the Expiration field on the STS response:
aws sts assume-role-with-web-identity \
--role-arn "arn:vast::${TENANT_NAME}:role/rw-role" \
--role-session-name rwuser1-session \
--web-identity-token "$ID_TOKEN" \
--endpoint-url "https://${VAST_VIP}" \
--no-verify-ssl \
| jq -r '.Credentials.Expiration'
# → "2026-04-20T18:15:27Z"To change max_session_duration on an existing role without re-running create-iam.sh, PATCH it directly via the VMS REST API:
# Admin token
TOKEN=$(curl -sk -X POST "https://${VMS_HOST}/api/token/" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg u "$VMS_USER" --arg p "$VMS_PASS" '{username:$u,password:$p}')" \
| jq -r '.access')
# Role id
ROLE_ID=$(curl -sk "https://${VMS_HOST}/api/iamroles/?name=rw-role" \
-H "Authorization: Bearer ${TOKEN}" | jq -r '.[0].id')
# Patch
curl -sk -X PATCH "https://${VMS_HOST}/api/iamroles/${ROLE_ID}/" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"max_session_duration": "900"}'Or via vcli if you prefer a CLI:
vcli: admin> iamrole modify --id <ROLE_ID> --max-session-duration 900For reference, creating a role from scratch with vcli (the equivalent of what create-iam.sh does via REST) looks like:
vcli: admin> iamrole create \
--name rw-role \
--tenant-id <TENANT_ID> \
--identity-policies-ids <POLICY_ID> \
--max-session-duration 900 \
--trust-policy '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"oidc-provider/<ISSUER>"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"<ISSUER>:aud":"vast-s3-rw"}}}]}'The demo uses the REST API directly so everything is idempotent and scriptable; the vcli form is shown here only as a quick human-CLI equivalent.
VMS REST API endpoints used by create-iam.sh
POST /api/token/— auth, returns{"access": "...", "refresh": "..."}GET /api/tenants/?name=<n>— tenant lookup by nameGET POST PATCH /api/s3policies/— both identity policies and bucket-attached policies live here. Thetenant_idfield on a record determines which tenant the policy is in. Theusersfield on a policy holds the names of any IAM roles attached to it.GET POST PATCH /api/iamroles/— IAM roles.trust_policyis a stringified JSON.s3_policiesis a write-only field accepting an array of policy IDs; VMS persists the relationship by adding the role name to each referenced policy'susersfield.max_session_durationis a string-typed field (seconds) capping the lifetime of any STS session assumed via this role — floor 900, default 129600 when omitted. See Credential lifetimes above.
PATCH quirk: VMS re-runs the name-uniqueness check on PATCH without excluding the row being updated, so PATCH bodies must omit name and tenant_id to avoid spurious 400 unique per Tenant errors. create-iam.sh handles this.
Keycloak admin API endpoints used by configure-keycloak-clients.sh
POST /realms/master/protocol/openid-connect/token— admin token viaadmin-cliclient.GET /admin/realms/<realm>/groups?search=<n>&exact=true— group lookup.GET POST /admin/realms/<realm>/rolesand/admin/realms/<realm>/roles/<name>— realm role create/get.GET POST /admin/realms/<realm>/groups/<id>/role-mappings/realm— group → realm role assignment.GET POST PUT /admin/realms/<realm>/clients— client create/update. TheauthenticationFlowBindingOverrides.direct_grantfield on the client object is what binds a custom flow.GET POST /admin/realms/<realm>/clients/<id>/protocol-mappers/models— protocol mapper create.GET POST /admin/realms/<realm>/authentication/flowsand.../flows/<alias>/copy— flow create/copy from built-in.POST /admin/realms/<realm>/authentication/flows/<alias>/executions/flow— add a sub-flow to a flow.POST /admin/realms/<realm>/authentication/flows/<alias>/executions/execution— add an execution to a flow or sub-flow.PUT /admin/realms/<realm>/authentication/flows/<alias>/executions— update an execution'srequirement(REQUIRED, ALTERNATIVE, CONDITIONAL, DISABLED).POST /admin/realms/<realm>/authentication/executions/<id>/config— set an execution's authenticator config (e.g. thecondUserRoleandnegatekeys forconditional-user-role).
Troubleshooting
aws sts assume-role-with-web-identity returns InvalidArgument: Unknown role. The IAM role doesn't exist in the target tenant. Re-run create-iam.sh and verify the role appears in the script's verification block. Make sure TENANT_NAME is set to the right tenant before running.
The token request returns unauthorized_client or invalid_grant. The user is not in the LDAP group whose realm role gates that client's flow. Either the user is genuinely not in the group, or the LDAP sync hasn't picked up a recent change. Trigger a manual sync via Keycloak admin UI or via POST /admin/realms/vast/user-storage/<ldap-component-id>/sync?action=triggerFullSync.
Couldn't parse condition_key when applying a trust policy. The condition key isn't on the supported list. Only aud, sub, email, and azp work in trust-policy conditions on VAST. Use one of those, or project your real claim into one of those four (the per-group client architecture in this demo does that for groups → aud).
put-bucket-policy hangs forever. Two known causes:
The specific VIP you're hitting is unresponsive. Switch the
--endpoint-urlto a different VIP in the same pool.LDAP is attached directly to the VAST tenant (see next item).
Do not attach an LDAP provider directly to a VAST tenant used for OIDC STS. On some VAST builds, attaching an LDAP provider to a tenant causes put-bucket-policy and delete-bucket-policy to hang for any policy that references a Group principal — and the symptom can spread to other policy operations. This demo keeps Keycloak as the sole LDAP consumer; VAST sees only OIDC STS sessions, never the LDAP directly. If you need LDAP attached to a VAST tenant for another reason, keep it separate from the tenant you use for this demo.
The negative test prints a parse error line then PASS. Cosmetic only. Keycloak's refusal response trips up jq when the script tries to extract .id_token. The empty result is correctly interpreted as a refusal, and the test records PASS.