Securing ISM policies with role-based access control
Lock down who can create, attach, and trigger OpenSearch Index State Management policies so that an unscoped credential can never fire a premature tier migration, delete indices on a schedule, or stall a replication pipeline.
Automated lifecycle transitions run with real destructive power: a single over-privileged token can force a hot → cold migration during peak ingest, break a follower’s write block, or lock an index read-only. This procedure maps the OpenSearch Security plugin permission model onto ISM-specific cluster and index actions, then walks through creating least-privilege roles, binding them to machine identities, and validating the boundary end to end. It builds directly on the Security & Access Boundaries baseline and the endpoints introduced in Index Lifecycle Basics.
Prerequisites
The ISM permission surface
The Security plugin intercepts every ISM REST call and every background scheduler task. Enforcement splits across two scopes: cluster-level permissions govern policy CRUD and scheduler execution, while index-level permissions govern policy attachment, state queries, and manual transitions. A scope mismatch surfaces as a security_exception mid-transition — often long after the role was created, when the scheduler first tries to act.
| Scope | Action | What it authorizes |
|---|---|---|
cluster |
cluster:admin/opendistro/ism/policy/* |
Create, update, delete, and enumerate ISM policies |
cluster |
cluster:monitor/opendistro/ism/policy/* |
Read policy metadata, execution history, scheduler health |
cluster |
cluster:admin/opendistro/ism/change |
Force manual state transitions across target indices |
index |
indices:admin/opendistro/ism/policy/* |
Attach, detach, or explain policies against explicit patterns |
index |
indices:admin/opendistro/ism/* |
Execute ISM-managed operations (rollover, shrink, force_merge) |
Least-privilege design separates policy architects from policy consumers. Architects — a small human group — hold cluster:admin/opendistro/ism/policy/*. Consumers — log ingestion pipelines, monitoring agents, CI/CD deployers — hold only indices:admin/opendistro/ism/policy/* scoped to explicit index patterns, and never cluster-level write or change actions.
Step-by-step: scoping RBAC for ISM policies
1. Define the least-privilege operator role
Create the role that automation will actually use. It can attach, detach, and explain policies on logs-* and metrics-*, and read scheduler state — but cannot create or delete a policy definition.
curl -sS -X PUT "https://<cluster-endpoint>:9200/_plugins/_security/api/roles/ism_operator" \
-H "Content-Type: application/json" \
--cacert /etc/opensearch/certs/ca.pem \
-u 'security_admin:REPLACE_ME' \
-d '{
"cluster_permissions": [
"cluster:monitor/opendistro/ism/policy/get"
],
"index_permissions": [
{
"index_patterns": ["logs-*", "metrics-*"],
"allowed_actions": [
"indices:admin/opendistro/ism/policy/attach",
"indices:admin/opendistro/ism/policy/detach",
"indices:admin/opendistro/ism/policy/explain"
]
}
]
}'
Expected response:
{ "status": "CREATED", "message": "'ism_operator' created." }
Gotcha: index_patterns are matched literally against the concrete index name at action time, not against the alias. A rollover write alias like logs-app produces backing indices such as logs-app-000001, which logs-* covers — but a bare pattern of logs-app (no wildcard) will not, and the attach silently 403s on the next backing index.
2. Map the role to a machine identity, not a human
Bind the role to the dedicated service account or an automation group via rolesmapping. Mapping to machine identities keeps credential rotation automatic and stops role scope from drifting when people change teams.
curl -sS -X PUT "https://<cluster-endpoint>:9200/_plugins/_security/api/rolesmapping/ism_operator" \
-H "Content-Type: application/json" \
--cacert /etc/opensearch/certs/ca.pem \
-u 'security_admin:REPLACE_ME' \
-d '{
"users": ["ism_automation_svc"],
"backend_roles": ["ism-automation"]
}'
Expected response:
{ "status": "OK", "message": "'ism_operator' updated." }
Gotcha: if the same principal is also mapped to a broad role such as all_access, OpenSearch takes the union of permissions. Least privilege only holds if the service account carries no other high-privilege mapping — verify with the check in step 5 before trusting the boundary.
3. Create a separate policy-architect role
Policy authorship is a distinct, higher-trust job. Keep it in its own role held by humans, so the automation credential can never rewrite or delete a policy definition.
curl -sS -X PUT "https://<cluster-endpoint>:9200/_plugins/_security/api/roles/ism_architect" \
-H "Content-Type: application/json" \
--cacert /etc/opensearch/certs/ca.pem \
-u 'security_admin:REPLACE_ME' \
-d '{
"cluster_permissions": [
"cluster:admin/opendistro/ism/policy/write",
"cluster:admin/opendistro/ism/policy/get",
"cluster:admin/opendistro/ism/policy/delete",
"cluster:admin/opendistro/ism/change"
],
"index_permissions": [
{
"index_patterns": ["logs-*", "metrics-*"],
"allowed_actions": ["indices:admin/opendistro/ism/*"]
}
]
}'
Gotcha: cluster:admin/opendistro/ism/change is what backs POST _plugins/_ism/change_policy — the ability to swap a running index onto a different state machine. Grant it only to architects; in the wrong hands it re-routes lifecycle mid-flight without touching the policy document, so it leaves almost no audit signal.
4. Attach a policy from the scoped service account
With the boundary in place, automation attaches policies over the _plugins/_ism/add/<index> endpoint using the operator credential. This Python client injects the credential from a vault, verifies TLS, and backs off on throttling — the same pattern used across the Python automation for dynamic ISM policy updates workflows.
import time
import requests
from requests.auth import HTTPBasicAuth
CLUSTER_URL = "https://<cluster-endpoint>:9200"
POLICY_ID = "hot_warm_cold_policy"
CA_BUNDLE = "/etc/opensearch/certs/ca.pem"
AUTH = HTTPBasicAuth("ism_automation_svc", "REPLACE_WITH_VAULT_TOKEN")
def attach_ism_policy(index_name: str, policy_id: str) -> dict:
"""Attach an ISM policy to one index, honouring RBAC and 429 backoff."""
endpoint = f"{CLUSTER_URL}/_plugins/_ism/add/{index_name}"
payload = {"policy_id": policy_id}
for attempt in range(3):
response = requests.post(
endpoint, json=payload, auth=AUTH,
verify=CA_BUNDLE, timeout=10,
)
if response.status_code == 403:
# RBAC denial — do not retry, the role is misscoped
raise PermissionError(f"RBAC denied: {response.text}")
if response.status_code == 429:
time.sleep(2 ** attempt) # scheduler saturated, back off
continue
response.raise_for_status()
return response.json()
raise RuntimeError("attach throttled after 3 attempts")
if __name__ == "__main__":
print(attach_ism_policy("logs-app-2026.07.04", POLICY_ID))
Expected response body on success:
{ "updated_indices": 1, "failures": false, "failed_indices": [] }
Gotcha: a 403 here is a role problem, never a transient one — the retry loop deliberately raises instead of retrying. See the official requests documentation for session pooling if you attach across many indices per run.
5. Scope Cross-Cluster Replication follower permissions
When ISM governs indices participating in replication, the follower cluster enforces its own boundary. A follower index needs indices:admin/opendistro/ism/policy/attach on the follower cluster to run local allocation or tier routing, and the replication account needs cluster:monitor/opendistro/ism/policy/* to poll scheduler progress. Add the follower grant to the operator role on that cluster:
curl -sS -X PATCH "https://<follower-endpoint>:9200/_plugins/_security/api/roles/ism_operator" \
-H "Content-Type: application/json" \
--cacert /etc/opensearch/certs/ca.pem \
-u 'security_admin:REPLACE_ME' \
-d '[{ "op": "add", "path": "/cluster_permissions/-",
"value": "cluster:monitor/opendistro/ism/policy/get" }]'
Gotcha: followers run under a write block, so rollover, force_merge, and shrink will fail even with the right permissions. Attach a read-optimized policy to followers and let those write actions happen on the leader — the same split covered in Index Lifecycle Basics. A misconfigured follower role otherwise produces a silent replication halt rather than a clean error.
Verification commands
Confirm the boundary is exactly what you intended before wiring automation into production.
Read back the active operator role and diff it against your intent:
curl -sS -X GET "https://<cluster-endpoint>:9200/_plugins/_security/api/roles/ism_operator" \
--cacert /etc/opensearch/certs/ca.pem -u 'security_admin:REPLACE_ME'
The allowed_actions array must contain only the three policy/{attach,detach,explain} actions — no cluster:admin/opendistro/ism/* should appear.
Resolve the service account’s effective permissions (the union across every mapping), which is the real boundary:
curl -sS -X GET "https://<cluster-endpoint>:9200/_plugins/_security/api/account" \
--cacert /etc/opensearch/certs/ca.pem -u 'ism_automation_svc:REPLACE_ME'
Expected roles field lists ism_operator and nothing privileged. If all_access or a *_admin role appears, least privilege is broken.
Confirm a managed index reports the expected policy and scheduler state:
curl -sS -X GET "https://<cluster-endpoint>:9200/_plugins/_ism/explain/logs-app-2026.07.04" \
--cacert /etc/opensearch/certs/ca.pem -u 'ism_automation_svc:REPLACE_ME'
A healthy response shows "policy_id": "hot_warm_cold_policy" and a populated state object. A bare {} or a security_exception means the account cannot read scheduler state — grant cluster:monitor/opendistro/ism/policy/get.
Common failures
| Symptom | Root cause | Fix command |
|---|---|---|
403 on _ism/add |
index_patterns do not match the concrete backing index name |
GET _plugins/_security/api/roles/ism_operator → widen pattern to logs-* |
| Rollover stalls, no error | Scheduler cannot read state; missing monitor permission | PATCH .../roles/ism_operator add cluster:monitor/opendistro/ism/policy/get |
security_exception on explain |
Account holds index attach but not cluster monitor scope | add cluster:monitor/opendistro/ism/policy/* and retry |
Follower replication halts on force_merge |
Write action attempted on a write-blocked follower index | detach write policy: POST _plugins/_ism/remove/<follower-index>; attach read-only policy |
| Least privilege “not working” | Service account also mapped to all_access (union of grants) |
GET .../api/account → remove the broad rolesmapping |
Frequently asked questions
Why do ISM actions use opendistro in the permission strings?
The action names predate the OpenSearch fork and were never renamed for compatibility. cluster:admin/opendistro/ism/policy/write and indices:admin/opendistro/ism/policy/attach are the current, correct strings on modern OpenSearch — do not substitute an opensearch namespace, it will not match.
Should I map ISM roles to users or to backend roles?
Prefer backend roles (LDAP/OIDC groups or IAM-derived roles) so access follows the identity provider and rotates with it. Map the internal users list only for a break-glass service account whose secret lives in a vault. Mapping a human’s internal user directly is the pattern that rots first.
Does attaching a policy require cluster-level write permission?
No. Attaching (_ism/add) and detaching (_ism/remove) are index-level actions — indices:admin/opendistro/ism/policy/attach and /detach. Only authoring the policy document needs cluster:admin/opendistro/ism/policy/write. That split is exactly what lets an operator account attach without ever being able to rewrite a policy.
Why did a transition run with more privilege than the operator role?
ISM executes a policy under the security context captured when the policy was attached, not the operator’s. If a broad admin attached it, later transitions inherit that scope. Re-attach from the scoped ism_operator account so the running context matches least privilege, as detailed in Security & Access Boundaries.
How do I unblock an index stuck because of an RBAC denial?
Fix the role scope first, then re-drive the failed step with POST _plugins/_ism/retry/<index>. Retrying before correcting the permission just re-hits the same 403. The retry mechanics are covered in Implementing retry logic for stuck ISM transitions.
Related
- Security & Access Boundaries — the parent guide to execution-context scoping and CCR credential isolation.
- Implementing retry logic for stuck ISM transitions — recovering the transitions an RBAC denial leaves wedged.
- Python automation for dynamic ISM policy updates — running attach/verify from the scoped service account in CI/CD.
- Mapping data tiers to OpenSearch node roles — the routing attributes a scoped policy is allowed to write.
Up: Security & Access Boundaries · OpenSearch ISM Architecture & Fundamentals