Skip to content

IAM and workload-identity decision lab

Exercises issuer, audience, subject, delegation, boundary, PassRole, and external-ID decisions against local fixtures.

Cloud Security 6 min read

Implementation: Tested

Implementation

evaluator.js JavaScript · 290 lines
labs/iam-oidc/evaluator.js
1"use strict";
2 
3const STAGES = Object.freeze({
4  SIGNATURE_INVALID: "SIGNATURE_INVALID",
5  CLAIMS_REJECTED: "CLAIMS_REJECTED",
6  TRUST_POLICY_REJECTED: "TRUST_POLICY_REJECTED",
7  PERMISSIONS_DENIED: "PERMISSIONS_DENIED",
8  AUTHORIZED: "AUTHORIZED",
9});
10 
11function asArray(value) {
12  if (value === undefined || value === null) {
13    return [];
14  }
15  return Array.isArray(value) ? value : [value];
16}
17 
18function globMatches(pattern, value) {
19  const escaped = String(pattern)
20    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
21    .replace(/\*/g, ".*")
22    .replace(/\?/g, ".");
23  return new RegExp(`^${escaped}$`).test(String(value));
24}
25 
26function anyPatternMatches(patterns, value) {
27  return asArray(patterns).some((pattern) => globMatches(pattern, value));
28}
29 
30function principalMatches(expectedPrincipal, actualPrincipal) {
31  if (expectedPrincipal === undefined) {
32    return true;
33  }
34  if (!actualPrincipal) {
35    return false;
36  }
37  if (typeof expectedPrincipal === "string") {
38    return anyPatternMatches(expectedPrincipal, actualPrincipal.value);
39  }
40  const expected = expectedPrincipal[actualPrincipal.type];
41  return expected !== undefined && anyPatternMatches(expected, actualPrincipal.value);
42}
43 
44function valuesEqual(actual, expected) {
45  const actualValues = asArray(actual).map(String);
46  const expectedValues = asArray(expected).map(String);
47  return actualValues.some((value) => expectedValues.includes(value));
48}
49 
50function valuesLike(actual, expected) {
51  const actualValues = asArray(actual);
52  const expectedValues = asArray(expected);
53  return actualValues.some((value) =>
54    expectedValues.some((pattern) => globMatches(pattern, value)),
55  );
56}
57 
58function conditionBlockMatches(operator, entries, context) {
59  return Object.entries(entries).every(([key, expected]) => {
60    const actual = context[key];
61 
62    switch (operator) {
63      case "StringEquals":
64      case "ArnEquals":
65        return valuesEqual(actual, expected);
66      case "StringLike":
67      case "ArnLike":
68        return valuesLike(actual, expected);
69      case "ForAllValues:StringEquals": {
70        const actualValues = asArray(actual);
71        const expectedValues = asArray(expected).map(String);
72        return actualValues.length > 0 &&
73          actualValues.every((value) => expectedValues.includes(String(value)));
74      }
75      case "Null": {
76        const isNull = actual === undefined || actual === null;
77        return String(isNull) === String(expected).toLowerCase();
78      }
79      default:
80        throw new Error(`Unsupported condition operator in lab model: ${operator}`);
81    }
82  });
83}
84 
85function conditionsMatch(conditions = {}, context = {}) {
86  return Object.entries(conditions).every(([operator, entries]) =>
87    conditionBlockMatches(operator, entries, context),
88  );
89}
90 
91function statementMatches(statement, request) {
92  const actionMatches = anyPatternMatches(statement.Action, request.action);
93  const resourceMatches = statement.Resource === undefined ||
94    anyPatternMatches(statement.Resource, request.resource || "*");
95 
96  return actionMatches &&
97    resourceMatches &&
98    principalMatches(statement.Principal, request.principal) &&
99    conditionsMatch(statement.Condition, request.context);
100}
101 
102function policyDecision(policy, request) {
103  const matching = asArray(policy.Statement).filter((statement) =>
104    statementMatches(statement, request),
105  );
106  if (matching.some((statement) => statement.Effect === "Deny")) {
107    return "explicitDeny";
108  }
109  if (matching.some((statement) => statement.Effect === "Allow")) {
110    return "allowed";
111  }
112  return "implicitDeny";
113}
114 
115function permissionIsAllowed(identityPolicies, boundary, request) {
116  const identityDecisions = asArray(identityPolicies).map((policy) =>
117    policyDecision(policy, request),
118  );
119  if (identityDecisions.includes("explicitDeny")) {
120    return false;
121  }
122  if (!identityDecisions.includes("allowed")) {
123    return false;
124  }
125  if (!boundary) {
126    return true;
127  }
128  return policyDecision(boundary, request) === "allowed";
129}
130 
131function tokenAudienceMatches(actual, expected) {
132  return asArray(actual).map(String).includes(String(expected));
133}
134 
135function validateToken(token, requirements) {
136  if (token.signatureVerified !== true) {
137    return {
138      stage: STAGES.SIGNATURE_INVALID,
139      reason: "The external cryptographic-verifier boundary did not authenticate the token signature.",
140    };
141  }
142 
143  const now = requirements.now;
144  const claimsValid =
145    token.issuer === requirements.expectedIssuer &&
146    tokenAudienceMatches(token.audience, requirements.expectedAudience) &&
147    Number.isFinite(token.notBefore) &&
148    Number.isFinite(token.expiresAt) &&
149    token.notBefore <= now &&
150    token.expiresAt > now;
151 
152  if (!claimsValid) {
153    return {
154      stage: STAGES.CLAIMS_REJECTED,
155      reason: "The authenticated token failed issuer, audience, or lifetime validation.",
156    };
157  }
158 
159  return null;
160}
161 
162function permissionStage(permissionSet) {
163  if (!permissionSet) {
164    return { stage: STAGES.AUTHORIZED, reason: "Trust was accepted; no post-assumption API was modeled." };
165  }
166 
167  const allowed = permissionIsAllowed(
168    permissionSet.identityPolicies,
169    permissionSet.boundary,
170    permissionSet.request,
171  );
172  return allowed
173    ? { stage: STAGES.AUTHORIZED, reason: "Trust and the modeled post-assumption authorization both allowed the request." }
174    : { stage: STAGES.PERMISSIONS_DENIED, reason: "The role session exists, but effective permissions denied the requested API." };
175}
176 
177function evaluateAwsWebIdentity(scenario) {
178  const tokenFailure = validateToken(scenario.token, scenario.claimRequirements);
179  if (tokenFailure) {
180    return tokenFailure;
181  }
182 
183  const prefix = scenario.claimRequirements.conditionPrefix;
184  const trustRequest = {
185    action: "sts:AssumeRoleWithWebIdentity",
186    principal: {
187      type: "Federated",
188      value: scenario.providerArn,
189    },
190    context: {
191      [`${prefix}:aud`]: scenario.token.audience,
192      [`${prefix}:sub`]: scenario.token.subject,
193    },
194  };
195 
196  if (policyDecision(scenario.trustPolicy, trustRequest) !== "allowed") {
197    return {
198      stage: STAGES.TRUST_POLICY_REJECTED,
199      reason: "The token claims were authentic and protocol-valid, but the role trust policy did not authorize this principal/subject.",
200    };
201  }
202 
203  return permissionStage(scenario.permissionSet);
204}
205 
206function evaluateAzureFederation(scenario) {
207  const tokenFailure = validateToken(scenario.token, scenario.claimRequirements);
208  if (tokenFailure) {
209    return tokenFailure;
210  }
211 
212  const credential = scenario.federatedCredential;
213  const trustMatches =
214    credential.issuer === scenario.token.issuer &&
215    credential.subject === scenario.token.subject &&
216    asArray(credential.audiences).length === 1 &&
217    tokenAudienceMatches(scenario.token.audience, credential.audiences[0]);
218 
219  if (!trustMatches) {
220    return {
221      stage: STAGES.TRUST_POLICY_REJECTED,
222      reason: "The token was protocol-valid, but it did not exactly match the configured federated credential.",
223    };
224  }
225 
226  const request = scenario.permissionSet.request;
227  const actionAllowed = asArray(scenario.permissionSet.allowedActions).some((action) =>
228    globMatches(action, request.action),
229  );
230  const scopeAllowed = asArray(scenario.permissionSet.allowedScopes).some((scope) =>
231    globMatches(scope, request.scope),
232  );
233 
234  return actionAllowed && scopeAllowed
235    ? { stage: STAGES.AUTHORIZED, reason: "Token exchange trust and the modeled Azure authorization both allowed the request." }
236    : { stage: STAGES.PERMISSIONS_DENIED, reason: "Token exchange succeeded, but the modeled Azure authorization denied the request." };
237}
238 
239function evaluateAwsAssumeRole(scenario) {
240  if (scenario.callerAuthenticated !== true) {
241    return {
242      stage: STAGES.SIGNATURE_INVALID,
243      reason: "The fixture boundary did not authenticate the calling AWS principal.",
244    };
245  }
246 
247  const context = {
248    "sts:ExternalId": scenario.externalId,
249    "aws:TagKeys": Object.keys(scenario.sessionTags || {}),
250  };
251  for (const [key, value] of Object.entries(scenario.sessionTags || {})) {
252    context[`aws:RequestTag/${key}`] = value;
253  }
254 
255  const baseRequest = {
256    principal: {
257      type: "AWS",
258      value: scenario.principalArn,
259    },
260    context,
261  };
262 
263  const assumeAllowed = policyDecision(scenario.trustPolicy, {
264    ...baseRequest,
265    action: "sts:AssumeRole",
266  }) === "allowed";
267  const tagsAllowed = Object.keys(scenario.sessionTags || {}).length === 0 ||
268    policyDecision(scenario.trustPolicy, {
269      ...baseRequest,
270      action: "sts:TagSession",
271    }) === "allowed";
272 
273  if (!assumeAllowed || !tagsAllowed) {
274    return {
275      stage: STAGES.TRUST_POLICY_REJECTED,
276      reason: "The caller, external ID, or requested session tags failed role-trust authorization.",
277    };
278  }
279 
280  return permissionStage(scenario.permissionSet);
281}
282 
283module.exports = {
284  STAGES,
285  evaluateAwsAssumeRole,
286  evaluateAwsWebIdentity,
287  evaluateAzureFederation,
288  permissionIsAllowed,
289  policyDecision,
290};

Run it

  • node labs/iam-oidc/tests/run-tests.js

This dependency-free offline lab tests exact workload-identity trust fixtures and a small, explicit subset of authorization semantics. It exists to prevent four different decisions from being collapsed into “the token worked”:

  1. SIGNATURE_INVALID - an external cryptographic-verifier boundary did not authenticate the token signature.
  2. CLAIMS_REJECTED - the signature boundary succeeded, but issuer, audience, or lifetime checks failed.
  3. TRUST_POLICY_REJECTED - the token/caller was authenticated and its protocol claims were accepted, but the role trust policy or federated credential rejected the principal, subject, external ID, or requested session tags.
  4. PERMISSIONS_DENIED - federation/role assumption succeeded, but the resulting session was not authorized for the requested API/resource.

AUTHORIZED means only that the deliberately small local model accepted the tested request. It is not evidence that a cloud provider issued credentials or allowed a real API call.

Prerequisites and run command

  • Node.js 24.12.0 (a compatible Node.js 22+ runtime is also supported).
  • No npm packages, credentials, network access, cloud subscription, or AWS account.

From the repository root:

node labs/iam-oidc/tests/run-tests.js

Expected output:

PASS: 45 IAM/OIDC structural and policy-model evaluations completed.

The command exits nonzero on any unexpected decision or structural regression.

Tested fixture coverage

Area Positive fixture Negative fixtures
AWS GitHub OIDC Exact provider, issuer, sts.amazonaws.com audience, repository, and main branch Invalid signature; wrong issuer/audience/repository/branch/provider; expired token
AWS GitHub environment Exact repository and production environment Branch subject presented to an environment trust; wrong environment
Microsoft Entra federation Exact GitHub issuer, subject, audience, action, and resource-group scope Invalid signature; wrong issuer/audience/environment; denied action/scope
EKS IRSA Exact cluster OIDC provider, issuer, audience, namespace, and service account Wrong cluster issuer/audience/namespace/service account; denied downstream API
Third-party AssumeRole Exact vendor role, customer external ID, and constrained session tags Wrong principal/external ID/tag; extra tag; missing sts:TagSession authorization
Permission-boundary delegation Create a role in the delegated path with the approved boundary Missing/alternate boundary; outside path; boundary removal/replacement
Restricted iam:PassRole Exact role, ECS service principal, and task-definition family Wrong role, destination service, or associated resource

The readable GitHub subject examples are exact fixture values. A branch subject and an environment subject are alternatives: when a job references an environment, the default subject uses the environment rather than the branch. GitHub also documents immutable owner/repository-ID subject formats for repositories created after July 15, 2026, repositories that opt in, and qualifying renamed/transferred repositories. Inspect the actual token claims for the repository, then make the cloud trust configuration match that format exactly.

Files and evidence boundary

  • policies/ contains illustrative AWS trust/permissions policies and a Microsoft Entra federated-credential object.
  • fixtures/ contains positive and negative requests. Every mutation declares its expected stage.
  • evaluator.js implements only the condition operators and policy behavior used by these fixtures.
  • tests/run-tests.js performs structural assertions and evaluates all fixtures using Node.js built-ins.

The fixture field "signatureVerified": true represents output received across a trusted in-process boundary from a real JWT/JWS verifier. Setting the field does not verify a signature. Production integration must discover and pin the intended issuer, select keys by kid, validate the allowed algorithm and signature, reject malformed tokens, enforce time and audience rules, and handle key rotation and outage behavior. An attacker-controlled build must not be able to supply or replace the verifier result.

The permission evaluator models explicit deny, identity-policy allow, resource/action matching, selected condition operators, and the intersection with one permissions boundary. It intentionally does not claim full AWS IAM semantics.

Production validation sequence

  1. Inspect provider-issued claims from a protected, non-production workflow or workload and record the exact issuer, audience, and subject format.
  2. Validate policy documents with provider tooling and review their deployment plan/change set.
  3. Run negative exchanges in a disposable test account/subscription: wrong repository/ref/environment/service account/audience/external ID and unapproved session tags must fail.
  4. After a successful exchange, call one explicitly allowed and one explicitly denied API on disposable resources. Capture provider audit evidence without recording bearer credentials.
  5. Test revocation/rollback, environment protection, permission-boundary immutability, and PassRole changes through the same governed path used in production.

These are documented production-validation steps, not actions performed by this lab.

AWS simulator limitations

The IAM Policy Simulator is useful for identity policies and one permissions boundary, but AWS documents material limitations: it does not simulate role/user cross-account access, resource-based policies for IAM roles, RCPs, or SCPs that contain conditions. It also does not perform GitHub or EKS OIDC discovery/signature validation, exchange a token through STS, or prove that a role trust policy admits a real federated principal. SimulatePrincipalPolicy/SimulateCustomPolicy results therefore cannot replace a negative AssumeRole or AssumeRoleWithWebIdentity test in a disposable AWS account. AWS recommends checking behavior in the live environment after simulation.

IAM Access Analyzer policy validation and external-access findings are valuable static checks, but they likewise do not establish successful end-to-end token verification or downstream authorization.

Failure modes and limitations

  • No cloud resource is created and no AWS, Azure, GitHub, or Kubernetes endpoint is called.
  • No JWT/JWS cryptography, JWKS retrieval, certificate path, key rotation, clock-skew policy, replay cache, or bearer-token handling is implemented.
  • The evaluator is not a general IAM interpreter. It omits resource policies, SCPs, RCPs, session policies, service-specific authorization, principal-ID transforms, policy variables, NotAction/NotResource/NotPrincipal, and most condition operators.
  • Microsoft Entra action/scope evaluation is a pedagogical allow-list, not the Azure RBAC engine. The fixture uses the baseline exact-match federated-credential model, not the separate flexible federated identity credential preview.
  • EKS fixtures cover IRSA. EKS Pod Identity uses a different trust model and should have separate tests when selected.
  • An external ID mitigates cross-account confused-deputy risk; AWS does not treat it as a secret. It does not replace a narrow principal or least-privilege role policy.
  • iam:PassRole authorization must be evaluated together with the destination API, the passed role's trust policy, and the passed role's permissions. This lab checks the exact PassRole dimensions and destination service trust, but does not call ECS.

Cleanup

The test reads tracked JSON and starts one Node.js process. It creates no temporary files, credentials, containers, or cloud resources, so no cleanup is required.

References