cloudarq-issuers 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ drift.txt
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 Abdallah Khaldi
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudarq-issuers
3
+ Version: 0.1.0
4
+ Summary: A census of the OIDC issuers clouds federate to for workload identity, with tenancy, subject grammar, claim vocabulary and who controls the audience.
5
+ Project-URL: Homepage, https://github.com/CloudArq-net/issuers
6
+ Project-URL: Source, https://github.com/CloudArq-net/issuers
7
+ Author: Abdallah Khaldi
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: aws,azure,federation,gcp,iam,oidc,trust-policy,workload-identity
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cloudarq-issuers
21
+
22
+ A census of the OIDC issuers a cloud provider will federate to for workload identity —
23
+ GitHub Actions, GitLab, CircleCI, HCP Terraform, Kubernetes and 35 others — with the fields
24
+ a policy evaluator actually needs.
25
+
26
+ **No dependencies. No network calls. Apache-2.0.**
27
+
28
+ ```bash
29
+ pip install cloudarq-issuers
30
+ ```
31
+
32
+ ## Why
33
+
34
+ Evaluating a cloud trust policy correctly means knowing things about the issuer that are not
35
+ in the policy: what its subject looks like, which claims it emits, whether it is shared
36
+ between every customer of that vendor. That knowledge is spread across forty vendor
37
+ documentation sites, and the closest public reference has not been updated since January 2025.
38
+
39
+ ## The four fields you will not find elsewhere
40
+
41
+ ### `aud_controlled_by` — who picks the audience
42
+
43
+ `aud` reads like a boundary. On **16 of 40** issuers it is not one, because the workload
44
+ requesting the token chooses it. From GitHub's own documentation: *"You can customize values
45
+ for `audience` claims."* So any repository on GitHub can request a token carrying
46
+ `aud: sts.amazonaws.com`, and a trust policy leaning on `aud` alone has trusted all of them.
47
+
48
+ ```python
49
+ from cloudarq_issuers import audience_is_boundary
50
+
51
+ audience_is_boundary("token.actions.githubusercontent.com") # False
52
+ audience_is_boundary("login.microsoftonline.com") # True
53
+ audience_is_boundary("nobody.surveyed.this") # None <- not False
54
+ ```
55
+
56
+ ### `vendor_example_verdict` — what the vendor's own example admits
57
+
58
+ Nobody writes a trust policy from scratch; they copy the documented example. A loose example
59
+ is therefore deployed at scale, silently. CircleCI's AWS example carries **no `sub` condition
60
+ at all**. Google's recommended attribute condition,
61
+ `assertion.repository_owner=='ORGANIZATION'`, admits every repository in the organisation.
62
+
63
+ ### `control_test` — what a bogus tenant returns
64
+
65
+ Two UUIDs invented at random each returned a complete, well-formed AWS discovery document
66
+ echoing the invented UUID back as its own `issuer`. Only the JWKS refused them.
67
+
68
+ > **A discovery document is not proof a tenant exists. Only the JWKS is.**
69
+
70
+ ### `immutable_id_claims` — names, or identifiers
71
+
72
+ **27 of 40** entries have none, so every subject written against them is a recyclable name.
73
+ Kubernetes is the sharp case: delete a service account, recreate one with the same name in
74
+ the same namespace, and it silently re-inherits every role that trusted it.
75
+
76
+ ## `unverified` never means absent
77
+
78
+ Sixteen entries were fetched live against the public `.well-known` endpoint on the census
79
+ date. Everything else says `unverified` — nobody confirmed it, which is a different claim
80
+ from *"there is nothing there"*. No field is inferred from another field.
81
+
82
+ The API keeps that distinction. `audience_is_boundary` returns `None`, never `False`, for
83
+ anything unconfirmed, because a caller that reads an unknown as a boundary has widened its
84
+ own trust without noticing.
85
+
86
+ ## API
87
+
88
+ ```python
89
+ import cloudarq_issuers as ci
90
+
91
+ ci.CENSUS_DATE # "2026-09-13"
92
+ ci.issuers() # every entry, as plain dicts
93
+ ci.get("gitlab.com") # one entry, or None if unsurveyed
94
+ ci.multi_tenant_hosts() # frozenset[str] -- issuers shared across a vendor's tenants
95
+ ci.audience_is_boundary(host) # True | False | None
96
+ ci.subjects_are_recyclable(host)
97
+ ci.host_of("https://gitlab.com/x") # "gitlab.com"
98
+ ```
99
+
100
+ `multi_tenant_hosts()` is a drop-in for a hard-coded set of the same shape.
101
+
102
+ ## Also available as a Go module
103
+
104
+ ```bash
105
+ go get github.com/CloudArq-net/issuers
106
+ ```
107
+
108
+ Same data, same source of truth. The YAML is authoritative and carries the comments; a
109
+ generated JSON is what both packages embed, which is why neither needs a YAML parser at
110
+ runtime and neither has a dependency.
111
+
112
+ ## Source, contributing, and how it stays current
113
+
114
+ [github.com/CloudArq-net/issuers](https://github.com/CloudArq-net/issuers)
115
+
116
+ A scheduled job re-fetches every discovery document and JWKS and opens an issue when anything
117
+ changes, so drift is visible rather than silent. Issuers move: Azure DevOps's `vstoken`
118
+ retires 2027-07-01, and AWS became an issuer in its own right at
119
+ `<uuid>.tokens.sts.global.api.aws` — `sts.amazonaws.com` is not one and returns 400 saying so.
120
+
121
+ An entry with `discovery_status: ok` must be reproducible: paste the `curl` and its output in
122
+ the pull request. Anything else is `unverified`, and that is a perfectly good state for an
123
+ entry to be in.
@@ -0,0 +1,104 @@
1
+ # cloudarq-issuers
2
+
3
+ A census of the OIDC issuers a cloud provider will federate to for workload identity —
4
+ GitHub Actions, GitLab, CircleCI, HCP Terraform, Kubernetes and 35 others — with the fields
5
+ a policy evaluator actually needs.
6
+
7
+ **No dependencies. No network calls. Apache-2.0.**
8
+
9
+ ```bash
10
+ pip install cloudarq-issuers
11
+ ```
12
+
13
+ ## Why
14
+
15
+ Evaluating a cloud trust policy correctly means knowing things about the issuer that are not
16
+ in the policy: what its subject looks like, which claims it emits, whether it is shared
17
+ between every customer of that vendor. That knowledge is spread across forty vendor
18
+ documentation sites, and the closest public reference has not been updated since January 2025.
19
+
20
+ ## The four fields you will not find elsewhere
21
+
22
+ ### `aud_controlled_by` — who picks the audience
23
+
24
+ `aud` reads like a boundary. On **16 of 40** issuers it is not one, because the workload
25
+ requesting the token chooses it. From GitHub's own documentation: *"You can customize values
26
+ for `audience` claims."* So any repository on GitHub can request a token carrying
27
+ `aud: sts.amazonaws.com`, and a trust policy leaning on `aud` alone has trusted all of them.
28
+
29
+ ```python
30
+ from cloudarq_issuers import audience_is_boundary
31
+
32
+ audience_is_boundary("token.actions.githubusercontent.com") # False
33
+ audience_is_boundary("login.microsoftonline.com") # True
34
+ audience_is_boundary("nobody.surveyed.this") # None <- not False
35
+ ```
36
+
37
+ ### `vendor_example_verdict` — what the vendor's own example admits
38
+
39
+ Nobody writes a trust policy from scratch; they copy the documented example. A loose example
40
+ is therefore deployed at scale, silently. CircleCI's AWS example carries **no `sub` condition
41
+ at all**. Google's recommended attribute condition,
42
+ `assertion.repository_owner=='ORGANIZATION'`, admits every repository in the organisation.
43
+
44
+ ### `control_test` — what a bogus tenant returns
45
+
46
+ Two UUIDs invented at random each returned a complete, well-formed AWS discovery document
47
+ echoing the invented UUID back as its own `issuer`. Only the JWKS refused them.
48
+
49
+ > **A discovery document is not proof a tenant exists. Only the JWKS is.**
50
+
51
+ ### `immutable_id_claims` — names, or identifiers
52
+
53
+ **27 of 40** entries have none, so every subject written against them is a recyclable name.
54
+ Kubernetes is the sharp case: delete a service account, recreate one with the same name in
55
+ the same namespace, and it silently re-inherits every role that trusted it.
56
+
57
+ ## `unverified` never means absent
58
+
59
+ Sixteen entries were fetched live against the public `.well-known` endpoint on the census
60
+ date. Everything else says `unverified` — nobody confirmed it, which is a different claim
61
+ from *"there is nothing there"*. No field is inferred from another field.
62
+
63
+ The API keeps that distinction. `audience_is_boundary` returns `None`, never `False`, for
64
+ anything unconfirmed, because a caller that reads an unknown as a boundary has widened its
65
+ own trust without noticing.
66
+
67
+ ## API
68
+
69
+ ```python
70
+ import cloudarq_issuers as ci
71
+
72
+ ci.CENSUS_DATE # "2026-09-13"
73
+ ci.issuers() # every entry, as plain dicts
74
+ ci.get("gitlab.com") # one entry, or None if unsurveyed
75
+ ci.multi_tenant_hosts() # frozenset[str] -- issuers shared across a vendor's tenants
76
+ ci.audience_is_boundary(host) # True | False | None
77
+ ci.subjects_are_recyclable(host)
78
+ ci.host_of("https://gitlab.com/x") # "gitlab.com"
79
+ ```
80
+
81
+ `multi_tenant_hosts()` is a drop-in for a hard-coded set of the same shape.
82
+
83
+ ## Also available as a Go module
84
+
85
+ ```bash
86
+ go get github.com/CloudArq-net/issuers
87
+ ```
88
+
89
+ Same data, same source of truth. The YAML is authoritative and carries the comments; a
90
+ generated JSON is what both packages embed, which is why neither needs a YAML parser at
91
+ runtime and neither has a dependency.
92
+
93
+ ## Source, contributing, and how it stays current
94
+
95
+ [github.com/CloudArq-net/issuers](https://github.com/CloudArq-net/issuers)
96
+
97
+ A scheduled job re-fetches every discovery document and JWKS and opens an issue when anything
98
+ changes, so drift is visible rather than silent. Issuers move: Azure DevOps's `vstoken`
99
+ retires 2027-07-01, and AWS became an issuer in its own right at
100
+ `<uuid>.tokens.sts.global.api.aws` — `sts.amazonaws.com` is not one and returns 400 saying so.
101
+
102
+ An entry with `discovery_status: ok` must be reproducible: paste the `curl` and its output in
103
+ the pull request. Anything else is `unverified`, and that is a perfectly good state for an
104
+ entry to be in.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cloudarq-issuers"
7
+ version = "0.1.0"
8
+ description = "A census of the OIDC issuers clouds federate to for workload identity, with tenancy, subject grammar, claim vocabulary and who controls the audience."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Abdallah Khaldi" }]
14
+ keywords = ["oidc", "workload-identity", "federation", "iam", "trust-policy", "aws", "azure", "gcp"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Systems Administration",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/CloudArq-net/issuers"
27
+ Source = "https://github.com/CloudArq-net/issuers"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/cloudarq_issuers"]
31
+
32
+ [tool.hatch.build.targets.sdist]
33
+ include = ["src/", "tests/", "README.md", "LICENSE"]
34
+
35
+ [tool.hatch.build.targets.wheel.force-include]
36
+ "src/cloudarq_issuers/issuers.json" = "cloudarq_issuers/issuers.json"
@@ -0,0 +1,138 @@
1
+ """A census of the OIDC issuers a cloud will federate to for workload identity.
2
+
3
+ No dependencies, no network calls. The data is a snapshot taken on
4
+ ``CENSUS_DATE``; it is not a live view.
5
+
6
+ Four fields are not published anywhere else, and three of them exist because the
7
+ obvious reading of the data is wrong:
8
+
9
+ ``aud_controlled_by``
10
+ Who picks the ``aud`` value. When it is ``"workload"``, ``aud`` is not a
11
+ boundary: anyone who can run a job on that platform can request a token
12
+ carrying your audience. Sixteen entries here are in that state, GitHub
13
+ Actions among them.
14
+
15
+ ``vendor_example_verdict``
16
+ What the vendor's own documented example actually admits, because a
17
+ documentation example is what ends up deployed.
18
+
19
+ ``control_test``
20
+ What a deliberately bogus tenant returned. AWS serves a complete,
21
+ well-formed discovery document for a UUID invented at random; only the JWKS
22
+ refuses it. A discovery document is not proof a tenant exists.
23
+
24
+ ``immutable_id_claims``
25
+ Empty for most entries, which means every subject written against them is a
26
+ recyclable name.
27
+
28
+ Anything nobody confirmed says ``"unverified"``. That never means ``"absent"``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from functools import lru_cache
35
+ from pathlib import Path
36
+ from typing import Any, Mapping, Optional
37
+
38
+ __all__ = [
39
+ "CENSUS_DATE",
40
+ "issuers",
41
+ "get",
42
+ "multi_tenant_hosts",
43
+ "audience_is_boundary",
44
+ "subjects_are_recyclable",
45
+ "host_of",
46
+ ]
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ _DATA = Path(__file__).with_name("issuers.json")
51
+
52
+
53
+ @lru_cache(maxsize=1)
54
+ def _census() -> Mapping[str, Any]:
55
+ with _DATA.open(encoding="utf-8") as fh:
56
+ return json.load(fh)
57
+
58
+
59
+ def host_of(issuer_or_url: str) -> str:
60
+ """The bare hostname of an issuer URL: no scheme, no path, lowercased."""
61
+ h = issuer_or_url.removeprefix("https://").removeprefix("http://")
62
+ return h.split("/", 1)[0].lower()
63
+
64
+
65
+ @lru_cache(maxsize=1)
66
+ def _by_host() -> Mapping[str, Mapping[str, Any]]:
67
+ out = {}
68
+ for e in _census()["issuers"]:
69
+ u = e.get("issuer") or e.get("issuer_pattern") or ""
70
+ if u:
71
+ out[host_of(u)] = e
72
+ return out
73
+
74
+
75
+ CENSUS_DATE: str = _census()["census_date"]
76
+
77
+
78
+ def issuers() -> tuple[Mapping[str, Any], ...]:
79
+ """Every entry in the census."""
80
+ return tuple(_census()["issuers"])
81
+
82
+
83
+ def get(host_or_url: str) -> Optional[Mapping[str, Any]]:
84
+ """Look an issuer up by hostname or by full issuer URL.
85
+
86
+ Returns ``None`` when the host is not in the census, which means nobody has
87
+ surveyed it. It does not mean the host is safe.
88
+ """
89
+ return _by_host().get(host_of(host_or_url))
90
+
91
+
92
+ def multi_tenant_hosts() -> frozenset[str]:
93
+ """Hostnames of issuers shared between the tenants of one vendor.
94
+
95
+ A token from any tenant of these carries the same ``iss``, so conditioning on
96
+ the issuer alone does not identify a tenant. Drop-in for a hard-coded set of
97
+ the same shape.
98
+ """
99
+ return frozenset(
100
+ host_of(e.get("issuer") or e.get("issuer_pattern") or "")
101
+ for e in _census()["issuers"]
102
+ if e.get("tenancy_model") in ("shared", "per_tenant_path")
103
+ and (e.get("issuer") or e.get("issuer_pattern"))
104
+ )
105
+
106
+
107
+ def audience_is_boundary(host_or_url: str) -> Optional[bool]:
108
+ """Does conditioning on ``aud`` constrain anything for this issuer?
109
+
110
+ ``True`` -- the relying party or the issuer fixes it.
111
+ ``False`` -- the workload picks it, so ``aud`` constrains nothing.
112
+ ``None`` -- nobody confirmed it, or the host is not in the census.
113
+
114
+ ``None`` must not be read as ``True``. A caller that treats an unknown as a
115
+ boundary has widened its own trust without noticing.
116
+ """
117
+ e = get(host_or_url)
118
+ if e is None:
119
+ return None
120
+ v = e.get("aud_controlled_by")
121
+ if v == "workload":
122
+ return False
123
+ if v in ("relying_party", "issuer"):
124
+ return True
125
+ return None
126
+
127
+
128
+ def subjects_are_recyclable(host_or_url: str) -> Optional[bool]:
129
+ """Does this issuer offer no immutable identifier at all?
130
+
131
+ When ``True``, every subject written against it is a name that can be
132
+ released and re-registered by someone else. ``None`` when the host is not in
133
+ the census.
134
+ """
135
+ e = get(host_or_url)
136
+ if e is None:
137
+ return None
138
+ return not e.get("immutable_id_claims")
@@ -0,0 +1 @@
1
+ {"census_date":"2026-09-13","issuers":[{"name":"Bitbucket Pipelines","vendor":"Atlassian Bitbucket","category":"ci","issuer":"https://api.bitbucket.org/2.0/workspaces/<workspace>/pipelines-config/identity/oidc","issuer_pattern":"https://api.bitbucket.org/2.0/workspaces/<ws>/pipelines-config/identity/oidc","tenancy_model":"per_tenant_path","control_test":"PASSED — bogus workspace returns HTTP 404","control_test_result":"passed","discovery_url":"https://api.bitbucket.org/2.0/workspaces/<workspace>/pipelines-config/identity/oidc/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"<repository_uuid>[:<environment_uuid>]:<step_uuid>","subject_examples":["{REPOSITORY_UUID}:{STEP_UUID}","{REPOSITORY_UUID}:{ENVIRONMENT_UUID}:{STEP_UUID}"],"claims":[],"immutable_id_claims":["repository_uuid","environment_uuid","step_uuid","workspace_uuid"],"aud_controlled_by":"issuer","aud_default":"ari:cloud:bitbucket::workspace/<workspace_uuid>","aud_evidence":"Atlassian docs give the audience as \"ari:cloud:bitbucket::workspace/{UUID}\" - derived from the workspace, not chosen by the pipeline. I found no documented way for a pipeline step to override it.","vendor_example_verdict":"admits_any_repo","vendor_example":"\"Condition\": {\n \"StringLike\": {\n \"api.bitbucket.org/2.0/workspaces/{WORKSPACE}/pipelines-config/identity/oidc:sub\": \"{REPO_UUID}:{*}\"\n }\n}\n","vendor_example_note":"Atlassian labels this \"A basic trust relationship allowing any repository in your workspace\". It pins a repo UUID and wildcards the rest, so it admits any step and any deployment environment of that repo. No aud condition is present in the quoted example, though the audience is workspace-pinned anyway.","self_hosted_variant":"n/a - Bitbucket Cloud only. Bitbucket Data Center OIDC not verified.","docs_url":"https://support.atlassian.com/bitbucket-cloud/docs/deploy-on-aws-using-bitbucket-pipelines-openid-connect/","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED. A bogus workspace slug (\"foo\") and a bogus workspace UUID both return HTTP 404 {\"type\": \"error\", \"error\": {\"message\": \"Resource not found\"}}. I could not fetch a positive discovery document because that requires naming a real workspace, so discovery_status stays `unverified` rather than being guessed from the 404 shape. Best-designed subject format in this census: every component is an immutable UUID, so no Bitbucket trust policy is name-recyclable."},{"name":"Buildkite","category":"ci","issuer":"https://agent.buildkite.com","tenancy_model":"shared","discovery_url":"https://agent.buildkite.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://agent.buildkite.com/.well-known/jwks","jwks_status":"ok","subject_format":"organization:<org_slug>:pipeline:<pipeline_slug>:ref:<ref>:commit:<commit>:step:<step_key>","subject_examples":["organization:acme-inc:pipeline:my-pipeline:ref:refs/heads/main:commit:abc123:step:deploy"],"claims":["sub","aud","exp","iat","iss","nbf","jti","organization_slug","pipeline_slug","build_number","build_branch","build_tag","build_commit","step_key","job_id","agent_id","build_source","runner_environment","organization_id","pipeline_id","build_id","cluster_id","cluster_name","queue_id","queue_key"],"claims_advertised":["agent_id","aud","build_branch","build_commit","build_id","build_number","build_source","build_tag","cluster_id","cluster_name","exp","iat","iss","job_id","jti","nbf","organization_id","organization_slug","pipeline_id","pipeline_slug","queue_id","queue_key","runner_environment","step_key","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["organization_id","pipeline_id","build_id","job_id","agent_id","cluster_id","queue_id"],"aud_controlled_by":"workload","aud_default":"https://buildkite.com/<ORGANIZATION_SLUG>","aud_evidence":"buildkite-agent oidc request-token: \"--audience string The audience that will consume the OIDC token. The API will choose a default audience if it is omitted.\" The pipeline step supplies the value.","vendor_example_verdict":"unverified","vendor_example_note":"No AWS trust policy example was present on the OIDC overview page I fetched; I did not locate a vendor AWS example to quote, so I am not rendering a verdict.","self_hosted_variant":"Tokens are requested by the agent but signed centrally by agent.buildkite.com even for self-hosted agents, so the issuer does not change. (Inferred from the single shared issuer in the discovery doc; not explicitly confirmed in docs.)","docs_url":"https://buildkite.com/docs/agent/v3/cli-oidc","verified_at":"2026-09-13","notes":"Notable: `--subject-claim` lets a pipeline override the token subject with \"An immutable claim to use as the token's subject (e.g. pipeline_id, cluster_id)\", so the sub format is not fixed. `--aws-session-tag` maps claims to AWS session tags."},{"name":"CircleCI","vendor":"CircleCI","category":"ci","issuer":"https://oidc.circleci.com/org/<organization_id>","issuer_pattern":"https://oidc.circleci.com/org/<org-uuid>","tenancy_model":"per_tenant_path","control_test":"FAILED — Any well-formed UUID returns HTTP 200 with a full discovery document and a live JWKS, and the kid is IDENTICAL across different bogus org UUIDs. Only UUID syntax is validated. CircleCI is a reflector, like GitHub. Per-tenancy is a documented architectural property, NOT verifiable by probing.","control_test_result":"failed","discovery_url":"https://oidc.circleci.com/org/<organization_id>/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://oidc.circleci.com/org/<organization_id>/.well-known/jwks-pub.json","jwks_status":"ok","subject_format":"org/<organization_id>/project/<project_id>/user/<user_id>","subject_examples":["org/<organization_id>/project/<project-id>/user/<user_id>","org/<organization_id>/project/<project-id>/user/<user_id>/vcs-origin/<vcs_origin>/vcs-ref/<vcs_ref>"],"claims":["aud","sub","iss","iat","exp","oidc.circleci.com/context-ids","oidc.circleci.com/job-id","oidc.circleci.com/org-id","oidc.circleci.com/pipeline-definition-id","oidc.circleci.com/pipeline-id","oidc.circleci.com/project-id","oidc.circleci.com/ssh-rerun","oidc.circleci.com/vcs-ref","oidc.circleci.com/vcs-origin","oidc.circleci.com/workflow-id"],"immutable_id_claims":["oidc.circleci.com/org-id","oidc.circleci.com/project-id","oidc.circleci.com/job-id","oidc.circleci.com/pipeline-id","oidc.circleci.com/workflow-id","oidc.circleci.com/pipeline-definition-id"],"aud_controlled_by":"workload","aud_default":"the organization ID (a UUID)","aud_evidence":"CircleCI docs: aud \"By default, this is `ORGANIZATION_ID`, a string containing a UUID that identifies the job's project's organization.\" The default is org-scoped, but the docs also describe custom claim generation that lets a job set the audience, so the issuer does not pin it.","vendor_example_verdict":"no_subject_constraint","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"oidc.circleci.com/org/<organization_id>:aud\": \"<organization_id>\",\n \"oidc.circleci.com/org/<organization_id>:oidc.circleci.com/project-id\": \"<project-id>\"\n }\n}\n","vendor_example_note":"CircleCI's own AWS example contains NO `sub` condition at all. It constrains aud and the project-id custom claim, so it admits every job of that project regardless of user, branch or VCS ref. Because project-id is an immutable UUID this is at least rename-proof, but any contributor able to run any job in that project gets the role.","self_hosted_variant":"n/a - CircleCI server self-hosted OIDC not verified.","docs_url":"https://circleci.com/docs/openid-connect-tokens/","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST FAILED - see findings. A deliberately bogus org UUID (00000000-0000-0000-0000-000000000000) returns HTTP 200 for BOTH the discovery document (echoing the bogus UUID as `issuer`) AND the JWKS, which serves a key byte-identical to the root https://oidc.circleci.com JWKS (kid MRvUxaues...). A non-UUID returns 400 \"invalid org id: must be a uuid\" - only the FORMAT is checked. All CircleCI orgs are signed by one shared key; the per-org issuer URL is a namespacing convention, not a cryptographic tenancy boundary."},{"name":"Codeberg (Forgejo)","category":"ci","issuer":"https://codeberg.org","tenancy_model":"shared","discovery_url":"https://codeberg.org/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://codeberg.org/login/oauth/keys","jwks_status":"ok","subject_format":"unverified","subject_examples":[],"claims":["aud","exp","iat","iss","sub","name","preferred_username","profile","picture","website","locale","updated_at","email","email_verified","groups"],"claims_advertised":["aud","email","email_verified","exp","groups","iat","iss","locale","name","picture","preferred_username","profile","sub","updated_at","website"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established.","vendor_example_verdict":"unverified","self_hosted_variant":"Forgejo is self-hostable; issuer is the instance origin.","docs_url":"https://codeberg.org/.well-known/openid-configuration","verified_at":"2026-09-13","notes":"Same caveat as Gitea: this is a human-SSO OIDC provider. Included for completeness of the Forgejo/Gitea family, not as a confirmed workload-identity issuer."},{"name":"Codefresh","category":"ci","issuer":"https://oidc.codefresh.io","tenancy_model":"shared","discovery_url":"https://oidc.codefresh.io/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://oidc.codefresh.io/jwks","jwks_status":"ok","subject_format":"account:<accountId>:pipeline:<pipelineId>:scm_repo_url:<scmRepoUrl>:scm_user_name:<scmUserName>:scm_ref:<ref>","subject_examples":["account:5f30eb00t788899:pipeline:64de5cd47626b3a:scm_repo_url:...:scm_user_name:...:scm_ref:..."],"claims":["sub","account_id","account_name","pipeline_id","pipeline_name","workflow_id","runner_environment","platform_url","initiator","scm_user_name","scm_repo_url","scm_ref","scm_pull_request_target_branch","sid","auth_time","iss"],"claims_advertised":["account_id","account_name","auth_time","initiator","iss","pipeline_id","pipeline_name","platform_url","runner_environment","scm_pull_request_target_branch","scm_ref","scm_repo_url","scm_user_name","sid","sub","workflow_id"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["account_id","pipeline_id","workflow_id"],"aud_controlled_by":"issuer","aud_default":"https://g.codefresh.io (SaaS); the instance URL on-premises","aud_evidence":"Codefresh docs: the audience is \"For SaaS, https://g.codefresh.io. For on-premises, this is the URL of your Codefresh instance.\"","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"oidc.codefresh.io:aud\": \"https://g.codefresh.io\"\n },\n \"StringLike\": {\n \"oidc.codefresh.io:sub\": \"account:5f30eb00t788899:pipeline:64de5cd47626b3a:*\"\n }\n}\n","vendor_example_note":"Pinned to a specific account AND pipeline by immutable ID, wildcarding only the scm fields - correctly scoped. BUT the aud condition is worthless: https://g.codefresh.io is the SAME audience for every Codefresh SaaS tenant, so `aud` provides zero cross-tenant isolation here. All separation rests on the account_id prefix inside `sub`.","self_hosted_variant":"On-premises Codefresh uses the instance URL as the audience; issuer for on-prem not verified.","docs_url":"https://codefresh.io/docs/docs/integrations/oidc-pipelines/","verified_at":"2026-09-13","notes":"Discovery doc omits `aud`, `exp`, `iat`, `nbf` and `jti` from claims_supported even though tokens plainly carry them - claims_supported is incomplete here, so I merged the vendor doc list. The `sub` leads with account_id, which is what makes it multi-tenant-safe."},{"name":"Depot","vendor":"Depot","category":"ci","issuer":"https://identity.depot.dev","tenancy_model":"shared","tenancy_claim":"org_id","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"spiffe://identity.depot.dev/org/<orgID>/ci/github/<owner>/<repo>/ref/<ref>/sandbox/<id>"},"token_lifetime_s":300,"aud_controlled_by":"workload","vendor_example_verdict":"scoped","verified_at":"2026-09-12"},{"name":"GitHub Actions","category":"ci","issuer":"https://token.actions.githubusercontent.com","tenancy_model":"shared","discovery_url":"https://token.actions.githubusercontent.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://token.actions.githubusercontent.com/.well-known/jwks","jwks_status":"ok","subject_format":"repo:<owner>/<repo>:<context>","subject_examples":["repo:octo-org/octo-repo:ref:refs/heads/octo-branch","repo:octo-org/octo-repo:environment:Production","repo:octo-org/octo-repo:pull_request"],"claims":["sub","aud","exp","iat","iss","jti","nbf","ref","sha","repository","repository_id","repository_owner","repository_owner_id","enterprise","enterprise_id","run_id","run_number","run_attempt","actor","actor_id","workflow","workflow_ref","workflow_sha","head_ref","base_ref","event_name","ref_type","ref_protected","environment","environment_node_id","job_workflow_ref","job_workflow_sha","repository_visibility","runner_environment","issuer_scope","check_run_id"],"claims_advertised":["actor","actor_id","aud","base_ref","check_run_id","enterprise","enterprise_id","environment","environment_node_id","event_name","exp","head_ref","iat","iss","issuer_scope","job_workflow_ref","job_workflow_sha","jti","nbf","ref","ref_protected","ref_type","repository","repository_id","repository_owner","repository_owner_id","repository_visibility","run_attempt","run_id","run_number","runner_environment","sha","sub","workflow","workflow_ref","workflow_sha"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["repository_id","repository_owner_id","actor_id","enterprise_id","environment_node_id"],"aud_controlled_by":"workload","aud_default":"the URL of the repository owner (e.g. https://github.com/<org>)","aud_evidence":"GitHub docs, \"OpenID Connect reference\": aud is \"By default, this is the URL of the repository owner, such as the organization that owns the repository.\" and \"You can customize values for `audience` claims.\" A workflow requests a token from ACTIONS_ID_TOKEN_REQUEST_URL with an `audience` query parameter, or via core.getIDToken(audience). The issuer does not constrain the value.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringLike\": {\n \"token.actions.githubusercontent.com:sub\": \"repo:octo-org/octo-repo:*\"\n },\n \"StringEquals\": {\n \"token.actions.githubusercontent.com:aud\": \"sts.amazonaws.com\"\n }\n}\n","vendor_example_note":"Pinned to one repository, so not org-wide. But the `*` admits ANY ref, ANY branch, ANY environment and pull_request contexts of that repo - i.e. anyone who can push a branch to that repo. The same page also shows a correctly pinned StringEquals variant on a specific ref. The `aud` condition adds nothing: any repo on GitHub can request aud=sts.amazonaws.com.","self_hosted_variant":"GitHub Enterprise Server issues from the appliance: provider URL \"https://HOSTNAME/_services/token\". Per-instance issuer, self-signed trust chain.","docs_url":"https://docs.github.com/en/actions/reference/security/oidc","verified_at":"2026-09-13","notes":"Discovery + JWKS fetched live. claims list taken verbatim from claims_supported. Note GCP's setup docs use issuer-uri \"https://token.actions.githubusercontent.com/\" WITH a trailing slash while the actual iss claim has none."},{"name":"GitHub Enterprise Server (self-hosted)","category":"ci","issuer":"https://<hostname>/_services/token","tenancy_model":"per_tenant_host","discovery_url":"https://<hostname>/_services/token/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"repo:<owner>/<repo>:<context>","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"workload","aud_default":"unverified","aud_evidence":"Not separately verified for GHES; GHES runs the same Actions token service, but I did not fetch a GHES instance to confirm.","vendor_example_verdict":"unverified","self_hosted_variant":"This IS the self-hosted variant. Issuer is the appliance hostname under /_services/token.","docs_url":"https://docs.github.com/en/enterprise-server@3.17/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws","verified_at":"2026-09-13","notes":"Issuer SHAPE confirmed from GitHub's GHES docs (\"For the provider URL: Use https://HOSTNAME/_services/token\"). No live endpoint exists to fetch - every GHES is private infrastructure. Deliberately left unverified rather than guessed."},{"name":"GitLab self-managed","category":"ci","issuer":"https://<gitlab-host>","tenancy_model":"per_tenant_host","discovery_url":"https://<gitlab-host>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<gitlab-host>/oauth/discovery/keys","jwks_status":"unverified","subject_format":"project_path:<group>/<project>:ref_type:<type>:ref:<branch>","subject_examples":[],"claims":[],"immutable_id_claims":["project_id","namespace_id","user_id"],"aud_controlled_by":"workload","aud_default":"none - required in .gitlab-ci.yml","aud_evidence":"Same id_tokens mechanism as gitlab.com.","vendor_example_verdict":"unverified","self_hosted_variant":"This IS the self-hosted variant.","docs_url":"https://docs.gitlab.com/ci/secrets/id_token_authentication/","verified_at":"2026-09-13","notes":"Issuer shape confirmed from vendor docs ('iss: Issuer of the token, which is the domain of the GitLab instance', example payload shows \"iss\": \"https://gitlab.example.com\"). Not fetched - no public instance probed. project_id/namespace_id are per-instance counters, so they are NOT globally unique across instances."},{"name":"GitLab.com","category":"ci","issuer":"https://gitlab.com","tenancy_model":"shared","discovery_url":"https://gitlab.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://gitlab.com/oauth/discovery/keys","jwks_status":"ok","subject_format":"project_path:<group>/<project>:ref_type:<type>:ref:<branch>","subject_examples":["project_path:mygroup/myproject:ref_type:branch:ref:main"],"claims":["iss","sub","aud","exp","nbf","iat","jti","project_id","project_path","namespace_id","namespace_path","user_id","user_login","user_email","pipeline_id","pipeline_source","job_id","ref","ref_type","ref_protected","environment","environment_protected","deployment_tier","runner_id","runner_environment","sha","ci_config_ref_uri","ci_config_sha","groups_direct","user_access_level"],"claims_advertised":["aud","ci_config_ref_uri","email","email_verified","environment","exp","family_name","given_name","groups","groups_direct","https://gitlab.org/claims/groups/developer","https://gitlab.org/claims/groups/maintainer","https://gitlab.org/claims/groups/owner","iat","iss","jti","name","nickname","picture","preferred_username","profile","project_path","ref_path","sha","sub","sub_legacy","website"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["project_id","namespace_id","user_id","pipeline_id","job_id","runner_id"],"aud_controlled_by":"workload","aud_default":"none - `aud` is required in the id_tokens block of .gitlab-ci.yml","aud_evidence":"GitLab docs: aud is \"Intended audience for the token... Specified in the ID tokens configuration.\" The .gitlab-ci.yml author writes it:\n job_with_id_tokens:\n id_tokens:\n FIRST_ID_TOKEN:\n aud: https://first.service.com\nAny project on gitlab.com can name any audience.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"gitlab.com:sub\": \"project_path:mygroup/myproject:ref_type:branch:ref:main\",\n \"gitlab.com:namespace_id\": \"12345\",\n \"gitlab.com:project_id\": \"67890\"\n }\n}\n","vendor_example_note":"The best vendor example in this census. No wildcard; pins group, project AND branch, and additionally pins the immutable numeric namespace_id and project_id, so the policy survives a project rename and does not follow a recycled path name.","self_hosted_variant":"Self-managed GitLab issues iss = the instance URL, e.g. https://gitlab.example.com. Same claim vocabulary. Per-instance issuer.","docs_url":"https://docs.gitlab.com/ci/secrets/id_token_authentication/","verified_at":"2026-09-13","notes":"iss is the bare instance origin with no path. Claim list from vendor docs table; the live discovery document is GitLab's general OAuth metadata and does not enumerate CI claims."},{"name":"Gitea / Forgejo Actions","category":"ci","issuer":"https://<instance-host>","tenancy_model":"per_tenant_host","discovery_url":"https://gitea.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://gitea.com/login/oauth/keys","jwks_status":"ok","subject_format":"unverified","subject_examples":[],"claims":["aud","exp","iat","iss","sub","name","preferred_username","profile","picture","website","locale","updated_at","email","email_verified","groups"],"claims_advertised":["aud","email","email_verified","exp","groups","iat","iss","locale","name","picture","preferred_username","profile","sub","updated_at","website"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established for the Actions workflow-token path.","vendor_example_verdict":"unverified","self_hosted_variant":"Every Gitea/Forgejo instance is its own issuer at its own origin.","docs_url":"https://gitea.com/.well-known/openid-configuration","verified_at":"2026-09-13","notes":"IMPORTANT CAVEAT: the discovery documents I fetched from gitea.com and codeberg.org (Forgejo) are USER-LOGIN OIDC providers - the claims are name/email/groups, i.e. a human SSO vocabulary, not a CI workload vocabulary. This is NOT evidence that Gitea Actions mints workload-identity tokens for cloud federation. Verified as an OIDC issuer; unverified as a workload-identity-federation issuer. Do not conflate the two."},{"name":"Harness","vendor":"Harness","category":"ci","issuer_pattern":"https://app.harness.io/ng/api/oidc/account/<ACCOUNT_ID>","tenancy_model":"per_tenant_path","control_test":"PASSED — well-formed but non-existent account IDs return HTTP 401","control_test_result":"passed","discovery_status":"unverified","jwks_status":"unverified","aud_controlled_by":"unverified","vendor_example_verdict":"unverified","known_defect":"The discovery document advertises jwks_uri as \".../.wellknown/jwks\" -- a non-standard path missing the hyphen. Strict OIDC clients that normalise the path to /.well-known/jwks receive HTTP 400 and fail to fetch keys.\n","verified_at":"2026-09-12"},{"name":"Harness","category":"ci","issuer":"unverified","tenancy_model":"unverified","discovery_url":"https://app.harness.io/ng/api/oidc/account/<account_id>/.well-known/openid-configuration","discovery_status":"error","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established - could not reach an unauthenticated metadata document.","vendor_example_verdict":"unverified","self_hosted_variant":"unverified","docs_url":"https://developer.harness.io/docs/platform/reference-architectures/aws-oidc-role-connector/","verified_at":"2026-09-13","notes":"Both https://app.harness.io/.well-known/openid-configuration and the per-account path returned HTTP 401 with a \"Harness Redirect\" HTML login page. The documentation URL returned by search 404s. Harness OIDC almost certainly exists (their docs describe AWS and GCP WIF) but I could not verify the issuer string, discovery document or JWKS without authenticating, which is out of scope. Deliberately left unverified."},{"name":"Jenkins (OpenID Connect Provider plugin)","category":"ci","issuer":"https://<jenkins-url>/oidc","tenancy_model":"per_tenant_host","discovery_url":"https://<jenkins-url>/oidc/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not verified - no public instance fetched.","vendor_example_verdict":"unverified","self_hosted_variant":"Jenkins is self-hosted by definition; issuer is the controller URL.","docs_url":"unverified","verified_at":"2026-09-13","notes":"Jenkins OIDC is provided by a community plugin and every issuer is a private controller URL. No public endpoint exists to fetch and I did not verify the plugin's exact path prefix or claim vocabulary. Entirely unverified - listed only so consumers know it belongs in the taxonomy."},{"name":"Microsoft (Azure DevOps)","vendor":"Microsoft (Azure DevOps)","category":"ci","issuer":"https://vstoken.dev.azure.com","tenancy_model":"shared","discovery_url":"https://vstoken.dev.azure.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://vstoken.dev.azure.com/.well-known/jwks","jwks_status":"ok","subject_format":"sc://<organization>/<project>/<service-connection-name>","subject_examples":[],"claims":["sub","aud","exp","iat","iss","nbf"],"claims_advertised":["aud","exp","iat","iss","nbf","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":[],"aud_controlled_by":"relying_party","aud_default":"api://AzureADTokenExchange","aud_evidence":"Entra docs: \"audiences lists the audiences that can appear in the external token. Required... The recommended value is 'api://AzureADTokenExchange'.\" The relying party (the Entra federated identity credential) declares the accepted audience.","vendor_example_verdict":"unverified","self_hosted_variant":"Azure DevOps Server (on-prem) not verified.","docs_url":"https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops","verified_at":"2026-09-12","notes":"worst-case entry -- nothing to scope on beyond sub"},{"name":"Namespace","vendor":"Namespace","category":"ci","issuer":"https://federation.namespaceapis.com","tenancy_model":"shared","tenancy_claim":"tenant_id","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"<workspace-id>/..."},"aud_controlled_by":"issuer","vendor_example_verdict":"scoped","verified_at":"2026-09-12"},{"name":"Semaphore","category":"ci","issuer":"https://<organization>.semaphoreci.com","tenancy_model":"per_tenant_host","discovery_url":"https://<organization>.semaphoreci.com/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"org:<org>:project:<project_id>:repo:<repo>:ref_type:<type>:ref:<ref>","subject_examples":["org:my-org:project:936a5312-a3b8-4921-8b3f-2cec8baac574:repo:web:ref_type:branch:ref:refs/heads/main"],"claims":[],"immutable_id_claims":["project_id"],"aud_controlled_by":"issuer","aud_default":"https://<organization>.semaphoreci.com","aud_evidence":"Semaphore's AWS example conditions on \"my-org.semaphoreci.com:aud\": \"https://my-org.semaphoreci.com\" - the org URL. I found no documented job-side override, but I did not confirm the absence of one, so treat `issuer` as probable not proven.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"my-org.semaphoreci.com:aud\": \"https://my-org.semaphoreci.com\",\n \"my-org.semaphoreci.com:sub\": \"org:my-org:project:936a5312-a3b8-4921-8b3f-2cec8baac574:repo:web:ref_type:branch:ref:refs/heads/main\"\n }\n}\n","vendor_example_note":"Fully pinned with StringEquals down to the branch, and the project is an immutable UUID. Correctly scoped.","self_hosted_variant":"unverified","docs_url":"https://docs.semaphore.io/using-semaphore/openid","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST INCONCLUSIVE - and this is the important part. A bogus org (xyzzy-bogus.semaphoreci.com) returns HTTP **200**, but the body is a 74 KB HTML marketing page, not a discovery document. So the \"bogus tenant returns non-200\" test does NOT hold for Semaphore; the correct discriminator is content-type, not status code. I did not enumerate real org names to obtain a positive fetch, so discovery stays unverified."},{"name":"HCP Terraform (Terraform Cloud)","category":"iac","issuer":"https://app.terraform.io","tenancy_model":"shared","discovery_url":"https://app.terraform.io/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://app.terraform.io/.well-known/jwks","jwks_status":"ok","subject_format":"organization:<org>:project:<project>:workspace:<workspace>:run_phase:<phase>","subject_examples":["organization:my-organization-name:project:Default Project:workspace:my-workspace-name:run_phase:apply"],"claims":["sub","aud","exp","iat","iss","jti","nbf","ref","terraform_run_phase","terraform_workspace_id","terraform_workspace_name","terraform_organization_id","terraform_organization_name","terraform_project_id","terraform_project_name","terraform_run_id","terraform_full_workspace"],"claims_advertised":["aud","exp","iat","iss","jti","nbf","ref","sub","terraform_full_workspace","terraform_organization_id","terraform_organization_name","terraform_project_id","terraform_project_name","terraform_run_id","terraform_run_phase","terraform_workspace_id","terraform_workspace_name"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["terraform_workspace_id","terraform_organization_id","terraform_project_id","terraform_run_id"],"aud_controlled_by":"workload","aud_default":"aws.workload.identity","aud_evidence":"HashiCorp docs: aud is \"Intended audience for the JWT. For example, aws.workload.identity for AWS. This can be customized.\" and TFC_AWS_WORKLOAD_IDENTITY_AUDIENCE \"Will be used as the `aud` claim for the identity token. Defaults to aws.workload.identity.\" A workspace variable sets it, so the workspace (the workload) chooses.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"SITE_ADDRESS:aud\": \"AUDIENCE_VALUE\",\n \"SITE_ADDRESS:sub\": \"organization:ORG_NAME:project:PROJECT_NAME:workspace:WORKSPACE_NAME:run_phase:RUN_PHASE\"\n }\n}\n","vendor_example_note":"StringEquals on a fully qualified subject - org, project, workspace and run phase all pinned, no wildcards. HashiCorp additionally writes that operators \"should always check, at minimum, the audience and the name of the organization in order to prevent unauthorized access.\" Correctly scoped. Caveat: the subject uses NAMES, not the immutable *_id claims, so it is recyclable if a workspace or org name is released.","self_hosted_variant":"Terraform Enterprise uses the instance URL as issuer: \"Full URL of HCP Terraform or the Terraform Enterprise instance which signed the JWT.\" Hence https://<tfe-host>.","docs_url":"https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens","verified_at":"2026-09-13","notes":"The default audience aws.workload.identity is shared by every HCP Terraform customer, so like Codefresh the aud condition gives no cross-tenant isolation at its default value."},{"name":"Pulumi Cloud","category":"iac","issuer":"https://api.pulumi.com/oidc","tenancy_model":"shared","discovery_url":"https://api.pulumi.com/oidc/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://api.pulumi.com/oidc/.well-known/jwks","jwks_status":"ok","subject_format":"pulumi:deploy:org:<org>:project:<project>:stack:<stack>:operation:<kind>:scope:write","subject_examples":["pulumi:deploy:org:my-org:project:my-project:stack:prod:operation:update:scope:write"],"claims":["sub","aud","iat","iss","jti","nbf"],"claims_advertised":["aud","iat","iss","jti","nbf","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["stackId","deployment"],"aud_controlled_by":"issuer","aud_default":"the Pulumi organization name","aud_evidence":"Pulumi docs: the audience is \"The name of the organization associated with the deployment.\" Organization-derived, not caller-chosen.","vendor_example_verdict":"unverified","self_hosted_variant":"Pulumi self-hosted issuer not verified.","docs_url":"https://www.pulumi.com/docs/pulumi-cloud/oidc/provider/","verified_at":"2026-09-13","notes":"The live discovery document advertises only the six standard claims and omits every Pulumi-specific claim the docs describe - claims_supported is materially incomplete. I have kept the fetched list and the vendor list in separate fields rather than merging them. aud = the org NAME (not an ID), so it is recyclable if an org name is released."},{"name":"Scalr","category":"iac","issuer":"https://scalr.io","tenancy_model":"shared","discovery_url":"https://scalr.io/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://scalr.io/.well-known/jwks","jwks_status":"ok","subject_format":"unverified","subject_examples":[],"claims":["sub","aud","exp","iat","iss","jti","nbf","ref","scalr_run_phase","scalr_workspace_id","scalr_workspace_name","scalr_environment_id","scalr_environment_name","scalr_account_id","scalr_account_name","scalr_run_id","scalr_tags"],"claims_advertised":["aud","exp","iat","iss","jti","nbf","ref","scalr_account_id","scalr_account_name","scalr_environment_id","scalr_environment_name","scalr_run_id","scalr_run_phase","scalr_tags","scalr_workspace_id","scalr_workspace_name","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["scalr_workspace_id","scalr_environment_id","scalr_account_id","scalr_run_id"],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established - I did not locate a Scalr doc page stating the aud rule.","vendor_example_verdict":"unverified","self_hosted_variant":"unverified","docs_url":"https://scalr.io/.well-known/openid-configuration","verified_at":"2026-09-13","notes":"SURPRISE - Scalr is NOT per-tenant despite per-account subdomains. A deliberately bogus subdomain (xyzzy-bogus.scalr.io) returns HTTP 200 with a valid discovery document whose issuer is the GLOBAL \"https://scalr.io\", byte-identical to the apex. Wildcard DNS and a wildcard cert serve the same single-issuer document on every subdomain. So the tenancy control test is not applicable: there is one issuer for all Scalr accounts, and tenant separation rests entirely on the scalr_account_id claim inside the token. Anyone configuring an IAM OIDC provider for Scalr must condition on scalr_account_id."},{"name":"Spacelift","vendor":"Spacelift","category":"iac","issuer":"https://<account>.app.spacelift.io","issuer_pattern":"https://<account>.app.spacelift.io","tenancy_model":"per_tenant_host","control_test":"PASSED — bogus subdomain returns HTTP 403 \"Account not found\"","control_test_result":"passed","discovery_url":"https://demo.app.spacelift.io/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://demo.app.spacelift.io/.well-known/jwks","jwks_status":"ok","subject_format":"space:<space_id>:(stack|module):<stack_id|module_id>:run_type:<run_type>:scope:<read|write>","subject_examples":["space:root:stack:my-stack:run_type:TRACKED:scope:write"],"claims":["aud","callerId","callerType","exp","iat","iss","jti","nbf","runId","runType","scope","spaceId","spacePath","sub"],"claims_advertised":["aud","callerId","callerType","exp","iat","iss","jti","nbf","runId","runType","scope","spaceId","spacePath","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["runId","callerId"],"aud_controlled_by":"issuer","aud_default":"<account>.app.spacelift.io","aud_evidence":"Spacelift docs: aud is \"The audience of the token. This is the hostname of your Spacelift account, for example demo.app.spacelift.io.\" Account-derived, not run-chosen.","vendor_example_verdict":"admits_whole_org","vendor_example":"\"Condition\": {\n \"StringLike\": {\n \"yourSpaceliftDomain:sub\": \"space:production-*:stack:*:run_type:*:scope:*\"\n }\n}\n","vendor_example_note":"The documented pattern wildcards stack, run_type and scope, and prefix-matches the space. It admits ANY stack in any space whose ID starts with \"production-\", under any run type, including scope:write. Note also `space:production-*` matches \"production-test\" and anything else sharing the prefix.","self_hosted_variant":"n/a - Spacelift is SaaS; self-hosted worker pools do not change the issuer.","docs_url":"https://docs.spacelift.io/integrations/cloud-providers/oidc/","verified_at":"2026-09-12","notes":"TENANCY CONTROL TEST PASSED. A bogus account (thisorgdoesnotexist-xyzzy.app.spacelift.io) returns HTTP 403 {\"errors\":[{\"message\":\"Account not found\"}]} while demo.app.spacelift.io returns a valid discovery document. Spacelift is one of only two per-tenant issuers whose aud is pinned by the issuer to a tenant-unique value - that makes aud a real control here."},{"name":"Terraform Enterprise (self-hosted)","category":"iac","issuer":"https://<tfe-hostname>","tenancy_model":"per_tenant_host","discovery_url":"https://<tfe-hostname>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<tfe-hostname>/.well-known/jwks","jwks_status":"unverified","subject_format":"organization:<org>:project:<project>:workspace:<workspace>:run_phase:<phase>","subject_examples":[],"claims":[],"immutable_id_claims":["terraform_workspace_id","terraform_organization_id","terraform_project_id"],"aud_controlled_by":"workload","aud_default":"aws.workload.identity","aud_evidence":"Same TFC_AWS_WORKLOAD_IDENTITY_AUDIENCE mechanism as HCP Terraform.","vendor_example_verdict":"unverified","self_hosted_variant":"This IS the self-hosted variant.","docs_url":"https://developer.hashicorp.com/terraform/cloud-docs/workspaces/dynamic-provider-credentials/workload-identity-tokens","verified_at":"2026-09-13","notes":"Issuer shape confirmed from vendor docs (\"Full URL of ... the Terraform Enterprise instance which signed the JWT\") but no instance fetched. Path shape for the discovery endpoint assumed to mirror app.terraform.io and is therefore marked unverified."},{"name":"env0","category":"iac","issuer":"https://login.app.env0.com/","tenancy_model":"shared","discovery_url":"https://login.app.env0.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://login.app.env0.com/.well-known/jwks.json","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"claims_advertised":["aud","auth_time","created_at","email","email_verified","exp","family_name","given_name","iat","identities","iss","name","nickname","phone_number","picture","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established.","vendor_example_verdict":"unverified","self_hosted_variant":"unverified","docs_url":"unverified","verified_at":"2026-09-13","notes":"CAUTION - this is probably the WRONG issuer for workload identity federation. login.app.env0.com is an Auth0 tenant serving env0's human login (issuer has a trailing slash, advertises authorization_endpoint/mfa_challenge_endpoint/registration_endpoint). That is a user-SSO provider, not a deployment-token issuer. I did not find and verify env0's workload-identity issuer. Listed as unverified rather than publishing the Auth0 endpoint as if it were the federation issuer."},{"name":"Amazon EKS (Kubernetes service account issuer / IRSA)","category":"runtime","issuer":"https://oidc.eks.<region>.amazonaws.com/id/<cluster_oidc_id>","tenancy_model":"per_tenant_host","discovery_url":"https://oidc.eks.<region>.amazonaws.com/id/<cluster_oidc_id>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://oidc.eks.<region>.amazonaws.com/id/<cluster_oidc_id>/keys","jwks_status":"unverified","subject_format":"system:serviceaccount:<namespace>:<serviceaccount_name>","subject_examples":["system:serviceaccount:default:my-service-account"],"claims":["iss","sub","aud","exp","iat","kubernetes.io"],"immutable_id_claims":[],"aud_controlled_by":"relying_party","aud_default":"sts.amazonaws.com","aud_evidence":"AWS EKS docs give the audience as sts.amazonaws.com and the trust policy conditions on \"$oidc_provider:aud\": \"sts.amazonaws.com\". AWS requires this fixed value for AssumeRoleWithWebIdentity via IRSA; the pod does not choose it.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub\": \"system:serviceaccount:default:my-service-account\",\n \"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:aud\": \"sts.amazonaws.com\"\n }\n}\n","vendor_example_note":"StringEquals on both sub and aud, pinned to one namespace and one service account name. Correctly scoped. The residual risk is not the example but the subject vocabulary - see immutable_id_claims (empty).","self_hosted_variant":"Self-managed Kubernetes can publish its own issuer (any HTTPS URL serving /.well-known/openid-configuration and a JWKS) via --service-account-issuer; commonly an S3/GCS bucket or a public URL. Shape is operator-chosen.","docs_url":"https://docs.aws.amazon.com/eks/latest/userguide/associate-service-account-role.html","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED. A bogus cluster id returns HTTP 404 {\"id\": \"...\",\"message\": \"ID is not found\"}. I did not fetch a positive document because that requires naming a real cluster, so discovery_status is `unverified`, not `ok`. CRITICAL: `sub` carries NO immutable identifier - it is namespace name + service account name. Delete a service account and recreate one with the same name in the same namespace and it silently re-inherits every IAM role trusting that subject. The projected token does carry kubernetes.io.serviceaccount.uid, but AWS trust policies cannot condition on nested claims, so in practice every IRSA trust policy is name-based and recyclable."},{"name":"Azure Kubernetes Service (AKS workload identity)","category":"runtime","issuer":"https://<region>.oic.prod-aks.azure.com/<tenant_id>/<cluster_uuid>/","tenancy_model":"per_tenant_host","discovery_url":"https://<region>.oic.prod-aks.azure.com/<tenant_id>/<cluster_uuid>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"system:serviceaccount:<namespace>:<serviceaccount_name>","subject_examples":[],"claims":["iss","sub","aud","exp","iat"],"immutable_id_claims":[],"aud_controlled_by":"relying_party","aud_default":"api://AzureADTokenExchange","aud_evidence":"Entra federated identity credentials declare the accepted audience; docs state \"The recommended value is 'api://AzureADTokenExchange'.\" Set on the Entra side.","vendor_example_verdict":"unverified","self_hosted_variant":"n/a - managed control plane.","docs_url":"unverified","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED. A bogus tenant/cluster UUID pair returns HTTP 404 WebContentNotFound. Issuer shape not confirmed against a positive fetch, so discovery_status is unverified. Same name-based `sub` problem as EKS."},{"name":"Cloudflare Access","category":"runtime","issuer":"https://<team-name>.cloudflareaccess.com","tenancy_model":"per_tenant_host","discovery_url":"https://<team-name>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"the Access application AUD tag (a per-application hex identifier) - shape known from Cloudflare's model but not confirmed from a page I fetched","aud_evidence":"Not established from a fetched document.","vendor_example_verdict":"unverified","self_hosted_variant":"n/a - SaaS.","docs_url":"unverified","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED. A bogus team name returns HTTP 404 on both /cdn-cgi/access/sso/oidc/.well-known/openid-configuration (empty body) and /cdn-cgi/access/certs (Cloudflare Access error page). I could not obtain a positive fetch without naming a real team, so both statuses stay unverified. Cloudflare Access primarily issues tokens for HUMAN access to applications; its use as a workload-identity issuer into clouds is not established here."},{"name":"Google Kubernetes Engine (GKE)","category":"runtime","issuer":"https://container.googleapis.com/v1/projects/<project>/locations/<location>/clusters/<cluster>","tenancy_model":"per_tenant_path","discovery_url":"https://container.googleapis.com/v1/projects/<project>/locations/<location>/clusters/<cluster>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"unverified","jwks_status":"unverified","subject_format":"system:serviceaccount:<namespace>:<serviceaccount_name>","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established in this pass.","vendor_example_verdict":"unverified","self_hosted_variant":"n/a","docs_url":"unverified","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED (in a fashion). A bogus project returns HTTP 403 PERMISSION_DENIED \"Permission denied on resource project bogus-xyzzy-proj\" rather than 404 - so a non-existent tenant is distinguishable, but by an authorization error. GKE Workload Identity normally federates in-project without an explicit OIDC provider; the container.googleapis.com issuer matters mainly for federating a GKE cluster into AWS or Azure. Not verified positively."},{"name":"HashiCorp Vault (as an OIDC provider)","category":"runtime","issuer":"https://<vault-addr>/v1/identity/oidc/provider/<provider_name>","tenancy_model":"per_tenant_host","discovery_url":"https://<vault-addr>/v1/identity/oidc/provider/<provider_name>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<vault-addr>/v1/identity/oidc/provider/<provider_name>/.well-known/keys","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established. Vault OIDC clients have a client_id which conventionally becomes the audience, but I did not verify this against a fetched page statement.","vendor_example_verdict":"unverified","self_hosted_variant":"Vault is self-hosted; \"Each Vault namespace has a default OIDC provider and key\", so the issuer is per-namespace as well as per-instance.","docs_url":"https://developer.hashicorp.com/vault/docs/secrets/identity/oidc-provider","verified_at":"2026-09-13","notes":"Issuer path shape confirmed from HashiCorp docs (example given as http://127.0.0.1:8200/v1/identity/oidc/provider/default). Per-namespace AND per-provider, so a single Vault can host many issuers. No public instance to fetch. sub construction and audience rule not verified."},{"name":"Modal","vendor":"Modal","category":"runtime","issuer":"https://oidc.modal.com","tenancy_model":"shared","tenancy_claim":"workspace_id","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"modal:workspace_id:<ws>:environment_name:<env>:app_name:<app>:function_name:<fn>:container_id:<cid>"},"aud_controlled_by":"issuer","vendor_example_verdict":"scoped","verified_at":"2026-09-12","notes":"all isolation rests on the sub prefix, because aud is shared"},{"name":"SPIFFE / SPIRE (OIDC Discovery Provider)","category":"runtime","issuer":"https://<oidc-discovery-host>","tenancy_model":"per_tenant_host","discovery_url":"https://<oidc-discovery-host>/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<oidc-discovery-host>/keys","jwks_status":"unverified","subject_format":"spiffe://<trust-domain>/<workload-path>","subject_examples":["spiffe://example.org/ns/default/sa/default"],"claims":["iss","sub","aud","exp","iat"],"immutable_id_claims":[],"aud_controlled_by":"workload","aud_default":"none - the audience is a required argument when fetching a JWT-SVID","aud_evidence":"A JWT-SVID is minted for an explicitly requested audience (the SPIFFE Workload API requires an audience parameter); SPIRE's AWS example uses an arbitrary operator-chosen value \"mys3\". The workload names the audience.","vendor_example_verdict":"scoped","vendor_example":"\"Condition\": {\n \"StringEquals\": {\n \"oidc-discovery.example.org:aud\": \"mys3\",\n \"oidc-discovery.example.org:sub\": \"spiffe://example.org/ns/default/sa/default\"\n }\n}\n","vendor_example_note":"StringEquals on a single fully qualified SPIFFE ID and a single audience. Correctly scoped. Note the audience \"mys3\" is operator-chosen and carries no tenancy meaning.","self_hosted_variant":"SPIRE is self-hosted by definition; the issuer is whatever host the OIDC Discovery Provider is published on, and the trust domain is operator-chosen.","docs_url":"https://spiffe.io/docs/latest/keyless/oidc-federation-aws/","verified_at":"2026-09-13","notes":"No public endpoint to fetch - every SPIRE deployment is private. Shape and example taken from spiffe.io docs. A SPIFFE ID is a path, not an ID: trust domains and workload paths are operator-chosen names and fully recyclable."},{"name":"Teleport","category":"runtime","issuer":"https://<cluster-name>.teleport.sh","tenancy_model":"per_tenant_host","discovery_url":"https://<cluster-name>.teleport.sh/.well-known/openid-configuration","discovery_status":"unverified","jwks_uri":"https://<cluster-name>.teleport.sh/.well-known/jwks.json","jwks_status":"unverified","subject_format":"unverified","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"unverified","aud_default":"unverified","aud_evidence":"Not established.","vendor_example_verdict":"unverified","self_hosted_variant":"Self-hosted Teleport uses the proxy service public address as issuer.","docs_url":"unverified","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED (negatively). A bogus cluster name does not resolve/complete TLS at all - curl: (35) SSL_ERROR_SYSCALL - so non-existent tenants are distinguishable before HTTP. No positive fetch attempted (would require naming a real customer cluster). Teleport Machine ID / workload identity issuer details not verified."},{"name":"AWS (outbound identity federation)","category":"cloud","issuer":"https://<tenant_uuid>.tokens.sts.global.api.aws","tenancy_model":"per_tenant_host","discovery_url":"https://<tenant_uuid>.tokens.sts.global.api.aws/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://<tenant_uuid>.tokens.sts.global.api.aws/.well-known/jwks.json","jwks_status":"unverified","subject_format":"<IAM principal ARN>","subject_examples":["arn:aws:iam::123456789012:role/DataProcessingRole"],"claims":["sub","iss","aud","exp","iat","jti","https://sts.amazonaws.com/"],"immutable_id_claims":[],"aud_controlled_by":"workload","aud_default":"none - the audience is a required parameter of the GetWebIdentityToken call","aud_evidence":"AWS IAM docs, \"Understanding token claims\": aud is \"The intended recipient for the token specified in the GetWebIdentityToken request\", example \"https://api.example.com\". The caller names the audience on every request.","vendor_example_verdict":"unverified","self_hosted_variant":"n/a","docs_url":"https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound_token_claims.html","verified_at":"2026-09-13","notes":"YES - AWS IS an OIDC issuer, but NOT at sts.amazonaws.com. https://sts.amazonaws.com/.well-known/openid-configuration returns HTTP 400 {\"message\":\"Invalid endpoint. Expected format: <tenant-id>.tokens.sts.global.api.aws\"}. AWS outbound identity federation gives each account an issuer at <tenant_uuid>.tokens.sts.global.api.aws. TENANCY CONTROL TEST FAILED ON DISCOVERY, PASSED ON JWKS - the single most important methodological result in this census. Two UUIDs I invented at random each returned HTTP 200 with a complete, well-formed discovery document echoing my invented UUID as the `issuer`. Their advertised jwks_uri then returned HTTP 400 {\"message\":\"Invalid tenant ID\"}. So the discovery document alone is NOT proof a tenant exists; only the JWKS is. Also note the docs' own example issuer (https://abc123-def456-ghi789-jkl012.tokens.sts.global.api.aws) is not a valid UUID and is rejected by the live service with \"Invalid tenant ID. Expected a valid UUID\". `sub` is the role ARN, which contains the account ID (immutable) but a role NAME that is recyclable."},{"name":"AWS STS (sts.amazonaws.com)","category":"cloud","issuer":"not an OIDC issuer","tenancy_model":"unverified","discovery_url":"https://sts.amazonaws.com/.well-known/openid-configuration","discovery_status":"error","jwks_uri":"n/a","jwks_status":"n/a","subject_format":"n/a","subject_examples":[],"claims":[],"immutable_id_claims":[],"aud_controlled_by":"relying_party","aud_default":"sts.amazonaws.com is an AUDIENCE VALUE, not an issuer","aud_evidence":"sts.amazonaws.com is the audience AWS expects in tokens presented to AssumeRoleWithWebIdentity (see the EKS and GitHub examples in this census).","vendor_example_verdict":"unverified","self_hosted_variant":"n/a","docs_url":"https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html","verified_at":"2026-09-13","notes":"Explicit refutation, because this is a common confusion. sts.amazonaws.com does NOT serve OIDC discovery: HTTP 400 {\"message\":\"Invalid endpoint. Expected format: <tenant-id>.tokens.sts.global.api.aws\"}. It is a RELYING PARTY (and an audience string), not an issuer. AWS's issuer role is the separate outbound-federation entry above."},{"name":"Google (accounts.google.com)","category":"cloud","issuer":"https://accounts.google.com","tenancy_model":"shared","discovery_url":"https://accounts.google.com/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://www.googleapis.com/oauth2/v3/certs","jwks_status":"ok","subject_format":"<numeric Google account / service account unique ID>","subject_examples":[],"claims":["iss","sub","aud","exp","iat","azp","email","email_verified","at_hash","hd","name","picture","given_name","family_name","locale"],"claims_advertised":["aud","email","email_verified","exp","family_name","given_name","iat","iss","name","picture","sub"],"claims_advertised_fetched_at":"2026-09-13","immutable_id_claims":["sub"],"aud_controlled_by":"workload","aud_default":"none - the audience is the OAuth client ID or an explicitly requested audience","aud_evidence":"Not quoted from a fetched page. For Google service account ID tokens the caller supplies the target audience on the token request, but I did not verify a documentation sentence establishing this, so treat `workload` here as PROBABLE, not proven.","vendor_example_verdict":"admits_whole_org","vendor_example":"gcloud iam workload-identity-pools providers create-oidc PROVIDER_ID \\\n --location=\"global\" \\\n --workload-identity-pool=\"POOL_ID\" \\\n --issuer-uri=\"https://token.actions.githubusercontent.com/\" \\\n --attribute-mapping=\"MAPPINGS\" \\\n --attribute-condition=\"CONDITIONS\"\n# recommended condition:\nassertion.repository_owner=='ORGANIZATION'\n","vendor_example_note":"This verdict is about GCP AS A RELYING PARTY (its documented example for trusting GitHub Actions), not about Google as an issuer. Google's recommended attribute condition is assertion.repository_owner=='ORGANIZATION' - which admits EVERY repository in that GitHub organisation, including forks pushed by anyone with write access to any repo in it, with google.subject mapped from assertion.sub. Google does warn: \"To help protect against spoofing threats, you must use an attribute condition that restricts access to tokens issued by your GitHub organization.\" Note also the issuer-uri here carries a TRAILING SLASH that the real GitHub iss claim does not have.","self_hosted_variant":"n/a","docs_url":"https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines","verified_at":"2026-09-13","notes":"accounts.google.com is a live, valid OIDC issuer and is accepted by AWS and Azure as a federation source. Its claims vocabulary is a human-identity vocabulary; `sub` is a stable opaque numeric ID, which makes it the one issuer here whose subject is inherently immutable."},{"name":"Microsoft Entra ID","category":"cloud","issuer":"https://login.microsoftonline.com/<tenant_id>/v2.0","tenancy_model":"per_tenant_path","discovery_url":"https://login.microsoftonline.com/<tenant_id>/v2.0/.well-known/openid-configuration","discovery_status":"ok","jwks_uri":"https://login.microsoftonline.com/<tenant_id>/discovery/v2.0/keys","jwks_status":"ok","subject_format":"<object id of the service principal / managed identity>","subject_examples":[],"claims":["iss","sub","aud","exp","iat","nbf","tid","oid","azp","appid"],"immutable_id_claims":["tid","oid"],"aud_controlled_by":"relying_party","aud_default":"api://AzureADTokenExchange when Entra is the relying party","aud_evidence":"Entra docs: \"audiences lists the audiences that can appear in the external token. Required... The recommended value is 'api://AzureADTokenExchange'. It says what Microsoft identity platform must accept in the aud claim in the incoming token.\"","vendor_example_verdict":"scoped","vendor_example":"{\n \"name\": \"Testing\",\n \"issuer\": \"https://token.actions.githubusercontent.com\",\n \"subject\": \"repo:octo-org/octo-repo:environment:Production\",\n \"description\": \"Testing\",\n \"audiences\": [\n \"api://AzureADTokenExchange\"\n ]\n}\n","vendor_example_note":"Entra's federated identity credential example pins an exact subject string including the environment. Standard federated identity credentials do not accept wildcards, which forces exact matching - structurally safer than the AWS StringLike pattern.","self_hosted_variant":"n/a - Entra is SaaS. Sovereign clouds use different login hosts.","docs_url":"https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust","verified_at":"2026-09-13","notes":"TENANCY CONTROL TEST PASSED, cleanly. A random well-formed non-existent GUID returns HTTP 400 \"AADSTS90002: Tenant '...' not found\", and the all-zero GUID returns \"AADSTS900021: ... may not be an empty GUID\". A real tenant returns a document whose issuer contains that tenant id. Note the /common and /organizations endpoints return issuer literally as \"https://login.microsoftonline.com/{tenantid}/v2.0\" - a TEMPLATE string, not a usable issuer. Claims list is the WIF-relevant subset, not exhaustive."},{"name":"Cognition","vendor":"Cognition","category":"ai-agent-platform","issuer":"https://app.devin.ai","tenancy_model":"shared","tenancy_claim":"org_id","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"org_id:<uuid>"},"aud_controlled_by":"workload","vendor_example_verdict":"no_subject_constraint","verified_at":"2026-09-12"},{"name":"Cursor","vendor":"Cursor","category":"ai-agent-platform","issuer":"https://api.cursor.com","tenancy_model":"shared","tenancy_claim":"team_id","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"user:<id>|service_account:<id>","projected":"team_id:<id>"},"token_lifetime_s":300,"aud_controlled_by":"workload","vendor_example_verdict":"admits_any_principal","vendor_example_note":"The same docs page documents the correct team_id form immediately below. This is a documentation-ergonomics failure, not a vendor that misunderstands the problem. Write it up that way.\n","correct_condition":{"aws":{"StringEquals":{"api.cursor.com:sub":"team_id:<YOUR_TEAM_ID>"}},"entra":{"subject":"team_id:<YOUR_TEAM_ID>"},"gcp":"assertion.sub == 'team_id:<YOUR_TEAM_ID>'"},"verified_at":"2026-09-12"},{"name":"Cursor","vendor":"Cursor","category":"ai-agent-platform","issuer":"https://api2.cursor.sh/cloud-agent/identity","tenancy_model":"shared","tenancy_claim":"team_id","discovery_status":"unverified","jwks_status":"unverified","aud_controlled_by":"workload","vendor_example_verdict":"unverified","verified_at":"2026-09-12","notes":"legacy endpoint, still live; a trust policy pinned here remains exploitable"},{"name":"Warp","vendor":"Warp","category":"ai-agent-platform","issuer":"https://app.warp.dev","tenancy_model":"shared","tenancy_claim":"teams","discovery_status":"unverified","jwks_status":"unverified","sub_grammar":{"default":"<principal-type>:<principal-id>","aws":"scoped_principal:<team-uid>/<principal-type>:<principal-id>"},"aud_controlled_by":"workload","vendor_example_verdict":"scoped","verified_at":"2026-09-12","notes":"only agent platform found shipping a distinct AWS-specific tenant-scoped sub form"},{"name":"Chainguard","vendor":"Chainguard","category":"platform","issuer":"https://issuer.enforce.dev","tenancy_model":"shared","tenancy_claim":"UNVERIFIED","discovery_status":"unverified","jwks_status":"unverified","subject_format":"unverified","aud_controlled_by":"unverified","vendor_example_verdict":"unverified","verified_at":"2026-09-12","notes":"discovery document confirmed live; grammar not yet confirmed from vendor docs"}]}
File without changes
@@ -0,0 +1,50 @@
1
+ import cloudarq_issuers as ci
2
+
3
+
4
+ def test_census_is_populated():
5
+ assert len(ci.issuers()) >= 40
6
+ assert ci.CENSUS_DATE
7
+
8
+
9
+ def test_controlled_vocabularies_hold():
10
+ aud = {"relying_party", "issuer", "workload", "unverified"}
11
+ verdict = {"scoped", "admits_whole_org", "admits_any_repo",
12
+ "admits_any_principal", "no_subject_constraint", "unverified"}
13
+ tenancy = {"shared", "per_tenant_host", "per_tenant_path", "unverified"}
14
+ for e in ci.issuers():
15
+ assert e["aud_controlled_by"] in aud, e["name"]
16
+ assert e["vendor_example_verdict"] in verdict, e["name"]
17
+ assert e["tenancy_model"] in tenancy, e["name"]
18
+ assert e.get("issuer") or e.get("issuer_pattern"), e["name"]
19
+
20
+
21
+ def test_lookup_by_host_and_url_agree():
22
+ a = ci.get("token.actions.githubusercontent.com")
23
+ b = ci.get("https://token.actions.githubusercontent.com")
24
+ assert a is not None and a == b
25
+ assert ci.get("example.invalid") is None
26
+
27
+
28
+ def test_unverified_audience_is_never_true():
29
+ """The safety property. An unknown must not read as a boundary."""
30
+ for e in ci.issuers():
31
+ u = e.get("issuer") or e.get("issuer_pattern")
32
+ got = ci.audience_is_boundary(u)
33
+ if e["aud_controlled_by"] == "unverified":
34
+ assert got is None, e["name"]
35
+ if e["aud_controlled_by"] == "workload":
36
+ assert got is False, e["name"]
37
+
38
+
39
+ def test_multi_tenant_hosts_are_bare_hostnames():
40
+ hosts = ci.multi_tenant_hosts()
41
+ assert len(hosts) >= 10
42
+ assert "token.actions.githubusercontent.com" in hosts
43
+ for h in hosts:
44
+ assert "/" not in h and "://" not in h
45
+
46
+
47
+ def test_unsurveyed_host_is_none_not_false():
48
+ """Not in the census means unsurveyed, never safe."""
49
+ assert ci.audience_is_boundary("nobody.surveyed.this") is None
50
+ assert ci.subjects_are_recyclable("nobody.surveyed.this") is None