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
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};
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const path = require("node:path");
6const {
7 STAGES,
8 evaluateAwsAssumeRole,
9 evaluateAwsWebIdentity,
10 evaluateAzureFederation,
11 permissionIsAllowed,
12 policyDecision,
13} = require("../evaluator");
14
15const lab = path.resolve(__dirname, "..");
16const fixturesDirectory = path.join(lab, "fixtures");
17const policiesDirectory = path.join(lab, "policies");
18let evaluationCount = 0;
19
20function readJson(filePath) {
21 return JSON.parse(fs.readFileSync(filePath, "utf8"));
22}
23
24function fixture(name) {
25 return readJson(path.join(fixturesDirectory, name));
26}
27
28function policy(name) {
29 return readJson(path.join(policiesDirectory, name));
30}
31
32function clone(value) {
33 return JSON.parse(JSON.stringify(value));
34}
35
36function setPath(target, dottedPath, value) {
37 const segments = dottedPath.split(".");
38 let cursor = target;
39 for (const segment of segments.slice(0, -1)) {
40 assert.notEqual(cursor[segment], undefined, `Unknown mutation path: ${dottedPath}`);
41 cursor = cursor[segment];
42 }
43 cursor[segments.at(-1)] = value;
44}
45
46function applyMutations(target, mutations) {
47 for (const [dottedPath, value] of Object.entries(mutations)) {
48 setPath(target, dottedPath, value);
49 }
50}
51
52function hydratePermissionSet(permissionSet) {
53 if (!permissionSet) {
54 return permissionSet;
55 }
56 permissionSet.identityPolicies = (permissionSet.identityPolicies || []).map((item) =>
57 typeof item === "string" ? policy(item) : item,
58 );
59 if (typeof permissionSet.boundary === "string") {
60 permissionSet.boundary = policy(permissionSet.boundary);
61 }
62 return permissionSet;
63}
64
65function expectStage(name, actual, expected) {
66 evaluationCount += 1;
67 assert.equal(
68 actual.stage,
69 expected,
70 `${name}: expected ${expected}, got ${actual.stage} (${actual.reason})`,
71 );
72}
73
74function runAwsWebIdentityFixture(document) {
75 const scenarios = document.scenarios || [document];
76 for (const scenario of scenarios) {
77 for (const testCase of scenario.cases) {
78 const input = clone(scenario.base);
79 input.trustPolicy = policy(scenario.trustPolicy);
80 hydratePermissionSet(input.permissionSet);
81 applyMutations(input, testCase.mutations);
82 expectStage(
83 `${scenario.name || scenario.trustPolicy}: ${testCase.name}`,
84 evaluateAwsWebIdentity(input),
85 testCase.expectedStage,
86 );
87 }
88 }
89}
90
91function runAzureFixture(document) {
92 for (const testCase of document.cases) {
93 const input = clone(document.base);
94 input.federatedCredential = policy(document.federatedCredential);
95 applyMutations(input, testCase.mutations);
96 expectStage(
97 testCase.name,
98 evaluateAzureFederation(input),
99 testCase.expectedStage,
100 );
101 }
102}
103
104function runAssumeRoleFixture(document) {
105 for (const testCase of document.cases) {
106 const input = clone(document.base);
107 input.trustPolicy = policy(document.trustPolicy);
108 hydratePermissionSet(input.permissionSet);
109 applyMutations(input, testCase.mutations);
110 expectStage(
111 testCase.name,
112 evaluateAwsAssumeRole(input),
113 testCase.expectedStage,
114 );
115 }
116}
117
118function runAuthorizationFixture(document) {
119 const identityPolicy = policy(document.identityPolicy);
120 for (const testCase of document.cases) {
121 evaluationCount += 1;
122 assert.equal(
123 permissionIsAllowed([identityPolicy], null, testCase.request),
124 testCase.expectedAllowed,
125 testCase.name,
126 );
127 }
128}
129
130function assertPolicyDocument(document, name) {
131 assert.equal(document.Version, "2012-10-17", `${name}: policy version`);
132 assert.ok(Array.isArray(document.Statement), `${name}: Statement must be an array`);
133 assert.ok(document.Statement.length > 0, `${name}: Statement must not be empty`);
134}
135
136function runStructuralTests() {
137 const policyFiles = fs.readdirSync(policiesDirectory)
138 .filter((name) => name.endsWith(".json") && name !== "azure-github-federated-credential.json");
139 for (const name of policyFiles) {
140 assertPolicyDocument(policy(name), name);
141 }
142
143 const githubMain = policy("aws-github-main-trust.json").Statement[0];
144 const githubEnvironment = policy("aws-github-production-trust.json").Statement[0];
145 for (const statement of [githubMain, githubEnvironment]) {
146 assert.equal(statement.Action, "sts:AssumeRoleWithWebIdentity");
147 assert.equal(
148 statement.Principal.Federated,
149 "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com",
150 );
151 const conditions = statement.Condition.StringEquals;
152 assert.equal(conditions["token.actions.githubusercontent.com:aud"], "sts.amazonaws.com");
153 assert.doesNotMatch(conditions["token.actions.githubusercontent.com:sub"], /[*?]/);
154 }
155 assert.equal(
156 githubMain.Condition.StringEquals["token.actions.githubusercontent.com:sub"],
157 "repo:example-security/cloud-controls:ref:refs/heads/main",
158 );
159 assert.equal(
160 githubEnvironment.Condition.StringEquals["token.actions.githubusercontent.com:sub"],
161 "repo:example-security/cloud-controls:environment:production",
162 );
163
164 const azureCredential = policy("azure-github-federated-credential.json");
165 assert.equal(azureCredential.issuer, "https://token.actions.githubusercontent.com");
166 assert.equal(
167 azureCredential.subject,
168 "repo:example-security/cloud-controls:environment:production",
169 );
170 assert.deepEqual(azureCredential.audiences, ["api://AzureADTokenExchange"]);
171
172 const irsa = policy("eks-irsa-trust.json").Statement[0];
173 assert.equal(irsa.Action, "sts:AssumeRoleWithWebIdentity");
174 assert.equal(
175 irsa.Condition.StringEquals[
176 "oidc.eks.ca-central-1.amazonaws.com/id/EXAMPLECLUSTERID:sub"
177 ],
178 "system:serviceaccount:payments:reconciler",
179 );
180 assert.equal(
181 irsa.Condition.StringEquals[
182 "oidc.eks.ca-central-1.amazonaws.com/id/EXAMPLECLUSTERID:aud"
183 ],
184 "sts.amazonaws.com",
185 );
186
187 const thirdParty = policy("third-party-trust.json");
188 const externalId =
189 thirdParty.Statement[0].Condition.StringEquals["sts:ExternalId"];
190 assert.equal(
191 externalId,
192 "example-tenant-a-7a7a7a7a-0000-4000-8000-000000000000",
193 );
194 assert.match(externalId, /^[\w+=,.@:\/-]{2,1224}$/);
195 assert.equal(thirdParty.Statement[1].Action, "sts:TagSession");
196 assert.deepEqual(
197 thirdParty.Statement[1].Condition["ForAllValues:StringEquals"]["aws:TagKeys"],
198 ["tenant-id", "workload"],
199 );
200
201 const delegated = policy("delegated-role-admin.json");
202 const create = delegated.Statement.find((statement) =>
203 statement.Sid === "CreateDelegatedRoleOnlyWithApprovedBoundary");
204 assert.equal(
205 create.Condition.ArnEquals["iam:PermissionsBoundary"],
206 "arn:aws:iam::111122223333:policy/DelegatedWorkloadBoundary",
207 );
208 const boundaryDeny = delegated.Statement.find((statement) =>
209 statement.Sid === "DenyBoundaryRemovalOrReplacement");
210 assert.equal(boundaryDeny.Effect, "Deny");
211 assert.deepEqual(
212 new Set(boundaryDeny.Action),
213 new Set(["iam:DeleteRolePermissionsBoundary", "iam:PutRolePermissionsBoundary"]),
214 );
215
216 const passRole = policy("restricted-passrole.json").Statement[0];
217 assert.equal(passRole.Action, "iam:PassRole");
218 assert.equal(
219 passRole.Resource,
220 "arn:aws:iam::111122223333:role/service/ecs/payments-task",
221 );
222 assert.equal(
223 passRole.Condition.StringEquals["iam:PassedToService"],
224 "ecs-tasks.amazonaws.com",
225 );
226 assert.equal(
227 policyDecision(policy("ecs-payments-task-trust.json"), {
228 action: "sts:AssumeRole",
229 principal: { type: "Service", value: "ecs-tasks.amazonaws.com" },
230 context: {},
231 }),
232 "allowed",
233 "The destination role must trust the service that receives it.",
234 );
235}
236
237runStructuralTests();
238runAwsWebIdentityFixture(fixture("github-oidc.json"));
239runAzureFixture(fixture("azure-federation.json"));
240runAwsWebIdentityFixture(fixture("eks-irsa.json"));
241runAssumeRoleFixture(fixture("third-party-assume-role.json"));
242runAuthorizationFixture(fixture("permission-boundary.json"));
243runAuthorizationFixture(fixture("passrole.json"));
244
245const requiredStages = new Set(Object.values(STAGES));
246const githubStages = new Set(
247 fixture("github-oidc.json").scenarios.flatMap((scenario) =>
248 scenario.cases.map((testCase) => testCase.expectedStage),
249 ),
250);
251assert.deepEqual(
252 githubStages,
253 requiredStages,
254 "GitHub OIDC fixtures must preserve all four rejection/authorization stages.",
255);
256
257console.log(
258 `PASS: ${evaluationCount} IAM/OIDC structural and policy-model evaluations completed.`,
259);
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”:
SIGNATURE_INVALID- an external cryptographic-verifier boundary did not authenticate the token signature.CLAIMS_REJECTED- the signature boundary succeeded, but issuer, audience, or lifetime checks failed.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.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.jsimplements only the condition operators and policy behavior used by these fixtures.tests/run-tests.jsperforms 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
- Inspect provider-issued claims from a protected, non-production workflow or workload and record the exact issuer, audience, and subject format.
- Validate policy documents with provider tooling and review their deployment plan/change set.
- Run negative exchanges in a disposable test account/subscription: wrong repository/ref/environment/service account/audience/external ID and unapproved session tags must fail.
- After a successful exchange, call one explicitly allowed and one explicitly denied API on disposable resources. Capture provider audit evidence without recording bearer credentials.
- Test revocation/rollback, environment protection, permission-boundary immutability, and
PassRolechanges 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:PassRoleauthorization 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
- AWS: Create a role for OIDC federation, including GitHub conditions
- AWS: IAM roles for EKS service accounts
- AWS: Assign an IAM role to an EKS service account
- AWS: Access to accounts owned by third parties
- AWS: Pass session tags in STS
- AWS: Permissions boundaries for IAM entities
- AWS: Grant permission to pass a role
- AWS: Policy evaluation logic
- AWS: IAM Policy Simulator capabilities and limitations
- Microsoft: Configure a user-assigned managed identity to trust an external identity provider
- Microsoft: Configure an application to trust an external identity provider
- GitHub: OpenID Connect reference and subject formats
- GitHub: Configure OIDC in AWS
- GitHub: Configure OIDC in Azure