Prepare oidc token for playbook execution in executor.

The OIDC secrets are decrypted togheter with normal secrets,
but the OIDC tokens are created right before the playbooks
execution if needed for those playbooks.

Specification: https://zuul-ci.org/docs/zuul/latest/developer/specs/zuul-workload-identity-federation.html

Change-Id: I6dcc0e807da86fd07deae0505d313c8297f1c096
This commit is contained in:
Dong Zhang
2025-02-26 16:23:03 +01:00
committed by James E. Blair
parent 73a31b3511
commit 52849f843c
18 changed files with 796 additions and 34 deletions

View File

@ -246,3 +246,9 @@ Version 34
:Prior Zuul version: 11.3.0
:Description: Don't store deprecated web ``status_url`` in system attributes anymore.
Affects schedulers and web.
Version 35
----------
:Prior Zuul version: 11.3.0
:Description: Updated Secret configuration foramat to support OIDC token.
Affects schedulers and executors.

View File

@ -683,12 +683,15 @@ class FakeBuild(object):
return False
def writeReturnData(self):
changes = self.executor_server.return_data.get(self.name, {})
data = changes.get(self.parameters['zuul']['ref'])
if data is None:
data_changes = self.executor_server.return_data.get(self.name, {})
secret_data_changes = self.executor_server.return_secret_data.get(
self.name, {})
data = data_changes.get(self.parameters['zuul']['ref'])
secret_data = secret_data_changes.get(self.parameters['zuul']['ref'])
if data is None and secret_data is None:
return
with open(self.jobdir.result_data_file, 'w') as f:
f.write(json.dumps({'data': data}))
f.write(json.dumps({'data': data, 'secret_data': secret_data}))
def hasChanges(self, *changes):
"""Return whether this build has certain changes in its git repos.
@ -1033,6 +1036,7 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
self.fail_tests = {}
self.retry_tests = {}
self.return_data = {}
self.return_secret_data = {}
self.job_builds = {}
def failJob(self, name, change):
@ -1061,7 +1065,7 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
dict(change=change,
retries=retries))
def returnData(self, name, change, data):
def returnData(self, name, change, data, secret_data={}):
"""Instruct the executor to return data for this build.
:arg str name: The name of the job to return data.
@ -1071,7 +1075,8 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
:arg dict data: The data to return
"""
changes = self.return_data.setdefault(name, {})
data_changes = self.return_data.setdefault(name, {})
secret_data_changes = self.return_secret_data.setdefault(name, {})
if hasattr(change, 'number'):
cid = change.data['currentPatchSet']['ref']
elif isinstance(change, str):
@ -1080,7 +1085,8 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
# Not actually a change, but a ref update event for tags/etc
# In this case a key of None is used by writeReturnData
cid = None
changes[cid] = data
data_changes[cid] = data
secret_data_changes[cid] = secret_data
def release(self, regex=None, change=None):
"""Release a held build.

View File

@ -20,6 +20,8 @@
- tenant:
name: tenant-two
max-nodes-per-job: 10
allowed-oidc-issuers:
- https://zuul.custom-issuer.com
allowed-triggers: gerrit
disallowed-labels:
- tenant-one-.*

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,38 @@
- secret:
name: oidc_secret
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- secret:
name: oidc_secret_iss_override_allowed
oidc:
ttl: 300
algorithm: RS256
iss: https://zuul.allowed.com
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- job:
parent: base
name: project2-oidc-secret
run: playbooks/secret.yaml
secrets:
- oidc_secret
- oidc_secret_iss_override_allowed
- project:
check:
jobs:
- project2-oidc-secret
gate:
jobs:
- noop

View File

@ -0,0 +1,65 @@
- secret:
name: project2_secret
data:
username: test-username
password: !encrypted/pkcs1-oaep |
BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
L0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4joeusC9drN3AA8a4o
ykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CRgd0QBMPl6VDoFgBPB8vxtJw+
3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzibDsSXsfJt1y+5n7yOURsC7lovMg4GF/v
Cl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCYceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qt
xhbpjTxG4U5Q/SoppOJ60WqEkQvbXs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYr
aI+AKYsMYx3RBlfAmCeC1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFW
Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
+150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
- secret:
name: project2_oidc_secret0
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- secret:
name: project2_oidc_secret1
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- secret:
name: project2_oidc_secret2
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- job:
parent: base
name: project2-oidc-secret
run: playbooks/secret.yaml
secrets:
- project2_secret
- project2_oidc_secret0
- project2_oidc_secret1
- project2_oidc_secret2
- project:
check:
jobs:
- project2-oidc-secret
gate:
jobs:
- noop

View File

@ -0,0 +1,70 @@
- secret:
name: project2-oidc-secret0
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- secret:
name: project2-oidc-secret1
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- secret:
name: project2-oidc-secret2
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- job:
parent: base
name: project2-dependent-with-return
run: playbooks/secret.yaml
- job:
parent: base
name: project2-parent
pre-run: playbooks/parent.yaml
post-run: playbooks/parent.yaml
- job:
parent: project2-parent
name: project2-oidc-secret
run: playbooks/secret.yaml
vars:
login_secret0: login_secret0_value
secrets:
- secret: project2-oidc-secret0
name: login_secret0 # Should override job var with same name
- secret: project2-oidc-secret1
name: login_secret1 # Should override zuul_return with same name
pass-to-parent: true # And should be available in parent job
- secret: project2-oidc-secret2
name: login_secret2
pass-to-parent: true # Should be available in parent job
- project:
check:
jobs:
- project2-dependent-with-return
- project2-oidc-secret:
dependencies:
- project2-dependent-with-return
gate:
jobs:
- noop

View File

@ -0,0 +1,25 @@
- secret:
name: project2_oidc_secret
oidc:
ttl: 300
algorithm: RS256
claims:
aud: sts.amazonaws.com
my_claim: my_claim_value
# Test it should not overwrite default claims
sub: some_sub_value
- job:
parent: base
name: project2-oidc-secret
run: playbooks/secret.yaml
secrets:
- project2_oidc_secret
- project:
check:
jobs:
- project2-oidc-secret
gate:
jobs:
- noop

View File

@ -1,5 +1,7 @@
- tenant:
name: tenant-one
allowed-oidc-issuers:
- https://zuul.allowed.com
source:
gerrit:
config-projects:

View File

@ -481,7 +481,11 @@ class TestJob(BaseTestCase):
pre_idx = job.pre_run[0]['secrets']['mysecret']
pre_secret = yaml.encrypted_load(
job.secrets[pre_idx]['encrypted_data'])
self.assertEqual(pre_secret, secret1_data)
expected = {
'secret_data': secret1_data,
'secret_oidc': {},
}
self.assertEqual(expected, pre_secret)
# Verify that they were deduplicated
pre2_idx = job.pre_run[0]['secrets']['othersecret']
@ -491,7 +495,11 @@ class TestJob(BaseTestCase):
run_idx = job.run[0]['secrets']['mysecret']
run_secret = yaml.encrypted_load(
job.secrets[run_idx]['encrypted_data'])
self.assertEqual(run_secret, secret2_data)
expected = {
'secret_data': secret2_data,
'secret_oidc': {},
}
self.assertEqual(expected, run_secret)
def _test_job_override_control(self, attr, job_attr,
default, default_value,

View File

@ -13,6 +13,7 @@
# under the License.
import json
import os
from zuul.zk.components import (
COMPONENT_REGISTRY,
@ -26,6 +27,7 @@ from tests.base import (
simple_layout,
iterate_timeout,
model_version,
FIXTURE_DIR,
ZOOKEEPER_SESSION_TIMEOUT,
)
from zuul import model
@ -489,3 +491,31 @@ class TestSemaphoreReleaseUpgrade(ZuulTestCase):
dict(name='test-global-semaphore',
result='SUCCESS', changes='2,1'),
], ordered=False)
class TestOidcSecretSupport(ZuulTestCase):
tenant_config_file = 'config/secrets/main.yaml'
@model_version(34)
def test_model_34(self):
self._run_test()
@model_version(35)
def test_model_35(self):
self._run_test()
def _run_test(self):
with open(os.path.join(FIXTURE_DIR,
'config/secrets/git/',
'org_project2/zuul-secret.yaml')) as f:
config = f.read()
file_dict = {'zuul.yaml': config}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1, "A should report success")
self.assertHistory([
dict(name='project2-secret', result='SUCCESS', changes='1,1'),
])

View File

@ -13,9 +13,11 @@
# License for the specific language governing permissions and limitations
# under the License.
import base64
import copy
import io
import json
import jwt
import logging
import os
import sys
@ -7268,6 +7270,147 @@ class TestMaxTimeout(ZuulTestCase):
"B should not fail because of timeout limit")
class TestOIDCConfiguration(ZuulTestCase):
tenant_config_file = 'config/multi-tenant/main.yaml'
def test_default_attributes(self):
# Test that the secret oidc config with all default configurations
# in different format.
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc1
oidc: {}
- secret:
name: my-oidc2
oidc: null
- secret:
name: my-oidc3
oidc:
""")
file_dict = {'.zuul.yaml': in_repo_conf}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn("Build succeeded.", A.messages[0],
"oidc config should allow null value")
def test_max_ttl_reached(self):
# Test that the secret oidc ttl is within the tenant max-oidc-ttl
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc
oidc:
ttl: 400
""")
file_dict = {'.zuul.yaml': in_repo_conf}
# max-oidc-ttl for tenant-one is 300, it should cause error
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn('The oidc secret "my-oidc" exceeds tenant max-oidc-ttl',
A.messages[0], "A should fail because of ttl limit")
# max-oidc-ttl for tenant-two is the default 10800,
# which should not cause error
B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertNotIn("exceeds tenant max-oidc-ttl", B.messages[0],
"B should not fail because of ttl limit")
def test_custom_issuer(self):
# Test that custom issuer is handled correctly
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc
oidc:
ttl: 200
iss: https://zuul.custom-issuer.com
""")
file_dict = {'.zuul.yaml': in_repo_conf}
# The issuer is not white listed in tenant one, it should cause error
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn(
'The iss "https://zuul.custom-issuer.com" in'
' oidc secret "my-oidc" is\n not allowed',
A.messages[0], "A should fail because the issuer is not allowed"
)
# The issuer is white listed for tenant-two, no error
B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertNotIn(
'The iss "https://zuul.custom-issuer.com" in'
' oidc secret "my-oidc" is\n not allowed',
B.messages[0], "B should not fail because the issuer is allowed"
)
def test_mutual_exclusive(self):
# Test that `oidc` and `data` should be mutually exclusive
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc
oidc: {}
data: {}
""")
file_dict = {'.zuul.yaml': in_repo_conf}
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn('two or more values in the same group'
' of exclusion \'secret_type\'',
A.messages[0],
"A should fail because of mutual exclusive")
def test_required(self):
# Test that one of `oidc` and `data` must be present
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc
""")
file_dict = {'.zuul.yaml': in_repo_conf}
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn('Either \'data\' or \'oidc\' must be present',
A.messages[0],
"A should fail because both are missing")
def test_unsupported_algorithm(self):
# Test that if the secret oidc algorithm is not supported,
# there should be an error message
in_repo_conf = textwrap.dedent(
"""
- secret:
name: my-oidc
oidc:
algorithm: XX256
""")
file_dict = {'.zuul.yaml': in_repo_conf}
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertIn("Algorithm 'XX256' is not supported",
A.messages[0],
"A should fail because of unsupported algorithm")
class TestAllowedConnection(AnsibleZuulTestCase):
config_file = 'zuul-connections-gerrit-and-github.conf'
tenant_config_file = 'config/multi-tenant/main.yaml'
@ -7847,6 +7990,192 @@ class TestSecrets(ZuulTestCase):
self.scheds.first.sched._runBlobStoreCleanup()
self.assertEqual(len(bs), 0)
def test_oidc_single(self):
# Test that oidc token is generated correctly when there is
# a single oidc secret defined.
with open(os.path.join(FIXTURE_DIR,
'config/secrets/git/',
'org_project2/zuul-oidc-single.yaml')) as f:
config = f.read()
file_dict = {'zuul.yaml': config}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1, "A should report success")
self.assertHistory([
dict(name='project2-oidc-secret', result='SUCCESS', changes='1,1')
])
secrets = self._getSecrets(
'project2-oidc-secret', 'playbooks'
)[0]
self.assertEqual(len(secrets), 1)
for secret_name, secret_content in secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value, self.history[0].uuid)
def test_oidc_multi(self):
# Test that oidc token is generated correctly when there are
# multiple oidc secrets defined.
with open(os.path.join(FIXTURE_DIR,
'config/secrets/git/',
'org_project2/zuul-oidc-multi.yaml')) as f:
config = f.read()
file_dict = {'zuul.yaml': config}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
# Mock zuul_return data defined in playbook
self.executor_server.returnData(
"project2-dependent-with-return", A, data={'foo': 'bar'},
secret_data={'login_secret1': "login_secret1_value"}
)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1, "A should report success")
self.assertHistory([
dict(
name='project2-dependent-with-return',
result='SUCCESS', changes='1,1'
),
dict(
name='project2-oidc-secret',
result='SUCCESS', changes='1,1'
)
])
secrets = self._getSecrets(
'project2-oidc-secret', 'playbooks'
)[0]
self.assertEqual(len(secrets), 3)
# OIDC secret should override job var with the same name
self.assertNotEqual(secrets['login_secret0'], 'login_secret0_value')
# OIDC secret should override zuul_return secret with the same name
self.assertNotEqual(secrets['login_secret1'], 'login_secret1_value')
# OIDC secret passed to parent
parent_pre_secrets = self._getSecrets(
'project2-oidc-secret', 'pre_playbooks'
)[0]
for secret_name, secret_content in parent_pre_secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value,
self.history[1].uuid, "parent.yaml")
parent_post_secrets = self._getSecrets(
'project2-oidc-secret', 'post_playbooks'
)[0]
for secret_name, secret_content in parent_post_secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value,
self.history[1].uuid, "parent.yaml")
for secret_name, secret_content in secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value,
self.history[1].uuid)
def test_oidc_mix(self):
# Test that oidc token is generated correctly when there are
# both oidc and normal secrets defined.
with open(os.path.join(FIXTURE_DIR,
'config/secrets/git/',
'org_project2/zuul-oidc-mix.yaml')) as f:
config = f.read()
file_dict = {'zuul.yaml': config}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1, "A should report success")
self.assertHistory([
dict(name='project2-oidc-secret', result='SUCCESS', changes='1,1')
])
secrets = self._getSecrets(
'project2-oidc-secret', 'playbooks'
)[0]
self.assertEqual(len(secrets), 4)
# Validate normal secrets
self.assertEqual(
secrets['project2_secret'],
{'username': 'test-username', 'password': 'test-password'}
)
# Remove the normal secret from the dict and validate the oidc tokens
del secrets['project2_secret']
for secret_name, secret_content in secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value, self.history[0].uuid)
def test_oidc_iss_override(self):
# Test that the custom 'iss' is allowed when configured
with open(os.path.join(
FIXTURE_DIR,
'config/secrets/git/',
'org_project2/zuul-oidc-iss-override.yaml')) as f:
config = f.read()
file_dict = {'zuul.yaml': config}
A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
files=file_dict)
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertEqual(A.reported, 1, "A should report success")
self.assertHistory([
dict(name='project2-oidc-secret', result='SUCCESS', changes='1,1')
])
secrets = self._getSecrets(
'project2-oidc-secret', 'playbooks'
)[0]
self.assertEqual(len(secrets), 2)
expected_isses = {
"oidc_secret": "https://zuul.example.com",
"oidc_secret_iss_override_allowed": "https://zuul.allowed.com",
}
for secret_name, secret_content in secrets.items():
self._validate_oidc_token(
secret_name, secret_content.value, self.history[0].uuid,
expected_iss=expected_isses[secret_name])
def _validate_oidc_token(self, oidc_name, oidc_token, build_uuid,
playbook="secret.yaml",
expected_iss="https://zuul.example.com"):
# Check header
header_base64 = oidc_token.split('.')[0]
header = json.loads(base64.b64decode(header_base64 + '==').decode())
self.assertEqual(header['alg'], 'RS256')
self.assertEqual(header['kid'], 'RS256-0')
self.assertEqual(header['typ'], 'JWT')
# Decode and check payload
keystore = self.scheds.first.sched.keystore
_, public_key, _ = (keystore.getLatestOidcSigningKeys("RS256"))
pem_public_key = encryption.serialize_rsa_public_key(public_key)
payload = jwt.decode(
oidc_token, pem_public_key, algorithms=["RS256"],
audience="sts.amazonaws.com")
self.assertEqual(payload['iss'], expected_iss)
self.assertEqual(
payload['sub'],
'secret:tenant-one/review.example.com'
f'/org/project2/{oidc_name}'
)
self.assertEqual(payload['build-uuid'], build_uuid)
self.assertEqual(payload['job-name'], 'project2-oidc-secret')
self.assertEqual(
payload['playbook'],
f'review.example.com/org/project2/playbooks/{playbook}'
)
self.assertEqual(payload['pipeline'], 'check')
self.assertEqual(payload['tenant'], 'tenant-one')
self.assertEqual(payload['aud'], 'sts.amazonaws.com')
self.assertEqual(payload['my_claim'], 'my_claim_value')
self.assertEqual(payload['exp'] - payload['iat'], 300)
class TestSecretInheritance(ZuulTestCase):
tenant_config_file = 'config/secret-inheritance/main.yaml'

View File

@ -42,6 +42,7 @@ from zuul.lib.varnames import check_varnames
from zuul.zk.components import COMPONENT_REGISTRY
from zuul.zk.semaphore import SemaphoreHandler
from zuul.exceptions import (
AlgorithmNotSupportedException,
CleanupRunDeprecation,
DuplicateGroupError,
DuplicateNodeError,
@ -638,12 +639,26 @@ class SecretParser(object):
self.pcontext = pcontext
self.schema = self.getSchema()
def _checkMissingAttribute(self, secret):
if 'data' not in secret and 'oidc' not in secret:
raise vs.Invalid("Either 'data' or 'oidc' must be present.")
return secret
def getSchema(self):
secret = {vs.Required('name'): str,
vs.Required('data'): dict,
'_source_context': model.SourceContext,
'_start_mark': model.ZuulMark,
}
oidc_schema = vs.Any({
'algorithm': str,
'ttl': int,
'iss': str,
'claims': dict,
}, None)
secret = vs.All({
vs.Required('name'): str,
vs.Exclusive('data', 'secret_type'): dict,
vs.Exclusive('oidc', 'secret_type'): oidc_schema,
'_source_context': model.SourceContext,
'_start_mark': model.ZuulMark,
}, self._checkMissingAttribute)
return vs.Schema(secret)
@ -652,7 +667,25 @@ class SecretParser(object):
s = model.Secret(conf['name'], conf['_source_context'])
s.source_context = conf['_source_context']
s.start_mark = conf['_start_mark']
s.secret_data = conf['data']
if 'data' in conf:
s.secret_data = conf['data']
else:
# TODO: A change to the global config would require
# reloading the file where the secret is defined; this may
# not be obvious to users, but it's also probably not
# something we need to immediately solve. It probably
# would be fixed by a full reconfiguration.
glbl = self.pcontext.scheduler.globals
if conf['oidc'] is None:
conf['oidc'] = {}
algorithm = conf['oidc'].get('algorithm')
if not algorithm:
conf['oidc']['algorithm'] = glbl.oidc_default_signing_algorithm
elif algorithm not in glbl.oidc_supported_signing_algorithms:
raise AlgorithmNotSupportedException(
f"Algorithm '{algorithm}' is not supported")
s.secret_oidc = conf['oidc']
return s
@ -1878,6 +1911,7 @@ class TenantParser(object):
'max-nodes-per-job': int,
'max-job-timeout': int,
'max-oidc-ttl': int,
'allowed-oidc-issuers': to_list(str),
'source': self.validateTenantSources(),
'exclude-unprotected-branches': bool,
'exclude-locked-branches': bool,
@ -1923,6 +1957,10 @@ class TenantParser(object):
tenant.max_job_timeout = int(conf['max-job-timeout'])
if conf.get('max-oidc-ttl') is not None:
tenant.max_oidc_ttl = int(conf['max-oidc-ttl'])
if conf.get('default-oidc-ttl') is not None:
tenant.default_oidc_ttl = int(conf['default-oidc-ttl'])
if conf.get('allowed-oidc-issuers') is not None:
tenant.allowed_oidc_issuers = as_list(conf['allowed-oidc-issuers'])
if conf.get('exclude-unprotected-branches') is not None:
tenant.exclude_unprotected_branches = \
conf['exclude-unprotected-branches']

View File

@ -248,6 +248,28 @@ class MaxTimeoutError(ConfigurationSyntaxError):
super(MaxTimeoutError, self).__init__(message)
class MaxOIDCTTLError(ConfigurationSyntaxError):
zuul_error_name = 'Max OIDC TTL Exceeded'
def __init__(self, secret, tenant):
message = textwrap.dedent("""\
The oidc secret "{secret}" exceeds tenant max-oidc-ttl {max_ttl}.""")
message = textwrap.fill(message.format(
secret=secret.name, max_ttl=tenant.max_oidc_ttl))
super(MaxOIDCTTLError, self).__init__(message)
class OIDCIssuerNotAllowedError(ConfigurationSyntaxError):
zuul_error_name = 'OIDC issuer not allowed'
def __init__(self, secret, issuer):
message = textwrap.dedent("""\
The iss "{issuer}" in oidc secret "{secret}" is not allowed.""")
message = textwrap.fill(message.format(
secret=secret.name, issuer=issuer))
super(OIDCIssuerNotAllowedError, self).__init__(message)
class DuplicateGroupError(ConfigurationSyntaxError):
zuul_error_name = 'Duplicate Nodeset Group'

View File

@ -66,6 +66,11 @@ class ExecutorClient(object):
uuid, self.sched.connections,
job, item, pipeline, dependent_changes, merger_items,
redact_secrets_and_keys=False)
# Pass webroot to the executor for generating oidc token
web_root = manager.tenant.web_root
if web_root:
params["zuul_root_url"] = web_root.split("/t/")[0].rstrip("/")
# TODO: deprecate and remove this variable?
params["zuul"]["_inheritance_path"] = list(job.inheritance_path)

View File

@ -18,6 +18,7 @@ import collections
import copy
import datetime
import json
import jwt
import logging
import multiprocessing
import os
@ -562,6 +563,7 @@ class JobDirPlaybook(object):
self.secrets = os.path.join(self.secrets_root, 'all.yaml')
self.secrets_content = None
self.secrets_keys = set()
self.secrets_oidc = {}
self.semaphores = []
self.nesting_level = None
self.cleanup = False
@ -2439,14 +2441,18 @@ class AnsibleJob(object):
for role in playbook['roles']:
self.prepareRole(jobdir_playbook, role, args)
secrets = self.decryptSecrets(playbook['secrets'])
secrets = self.mergeSecretVars(secrets)
secrets, jobdir_playbook.secrets_oidc = \
self.decryptSecrets(playbook['secrets'])
secrets = self.mergeSecretVars(secrets, jobdir_playbook.secrets_oidc)
if secrets:
check_varnames(secrets)
secrets = yaml.mark_strings_unsafe(secrets)
jobdir_playbook.secrets_content = yaml.ansible_unsafe_dump(
secrets, default_flow_style=False)
jobdir_playbook.secrets_keys = set(secrets.keys())
if jobdir_playbook.secrets_oidc:
jobdir_playbook.secrets_keys.update(
jobdir_playbook.secrets_oidc.keys())
self.writeAnsibleConfig(jobdir_playbook)
@ -2462,10 +2468,11 @@ class AnsibleJob(object):
:param dict secrets: The playbook secrets dictionary from the
scheduler
:returns: A decrypted secrets dictionary
:returns: Tuple of decrypted secrets dictionary and oidc dictionary
"""
ret = {}
ret_secret_data = {}
ret_secret_oidc = {}
with self.executor_server.zk_context as ctx:
blobstore = BlobStore(ctx)
for secret_name, secret_index in secrets.items():
@ -2476,15 +2483,26 @@ class AnsibleJob(object):
else:
frozen_secret = self.job.secrets[secret_index]
secret = zuul.model.Secret(secret_name, None)
secret.secret_data = yaml.encrypted_load(
decrypted_dict = yaml.encrypted_load(
frozen_secret['encrypted_data'])
private_secrets_key, public_secrets_key = \
self.executor_server.keystore.getProjectSecretsKeys(
frozen_secret['connection_name'],
frozen_secret['project_name'])
secret = secret.decrypt(private_secrets_key)
ret[secret_name] = secret.secret_data
return ret
if "secret_oidc" not in decrypted_dict:
# MODEL_API < 35
secret.secret_data = decrypted_dict
else:
secret.secret_data = decrypted_dict['secret_data']
secret.secret_oidc = decrypted_dict['secret_oidc']
if secret.secret_data:
private_secrets_key, public_secrets_key = \
self.executor_server.keystore.getProjectSecretsKeys(
frozen_secret['connection_name'],
frozen_secret['project_name'])
secret = secret.decrypt(private_secrets_key)
ret_secret_data[secret_name] = secret.secret_data
else:
ret_secret_oidc[secret_name] = secret.secret_oidc
return ret_secret_data, ret_secret_oidc
def checkoutTrustedProject(self, project, branch, args):
pi = self.jobdir.getTrustedProject(project.canonical_name,
@ -2606,11 +2624,12 @@ class AnsibleJob(object):
project.name)
return path
def mergeSecretVars(self, secrets):
def mergeSecretVars(self, secrets, secrets_oidc):
'''
Merge secret return data with secrets.
:arg secrets dict: Actual Zuul secrets.
:arg secrets_oidc dict: Actual Zuul oidc secrets.
'''
secret_vars = self.secret_vars
@ -2629,6 +2648,7 @@ class AnsibleJob(object):
other_vars.update(host_vars.keys())
other_vars.update(self.job.extra_variables.keys())
other_vars.update(secrets.keys())
other_vars.update(secrets_oidc.keys())
ret = secret_vars.copy()
for key in other_vars:
@ -3585,9 +3605,66 @@ class AnsibleJob(object):
now=datetime.datetime.now(),
msg=msg))
def _generateOidcTokens(self, playbook):
# In case web_root is not configured in zuul and tenant,
# 'zuul_root_url' would not be available in the arguments,
# just log a warning and skip generating oidc tokens
if 'zuul_root_url' not in self.arguments:
self.log.warning(
"web_root is not configured in zuul, "
"skipping oidc token generation")
return
oidc_tokens = {}
for oidc_name, oidc_config in playbook.secrets_oidc.items():
algorithm = oidc_config['algorithm']
private_secrets_key, _, version = \
self.executor_server.keystore.getLatestOidcSigningKeys(
algorithm=algorithm)
iat = int(time.time())
ttl = oidc_config['ttl']
exp = iat + ttl
tenant = self.arguments['zuul']['tenant']
canonical_project_name = \
self.arguments['zuul']['project']['canonical_name']
sub = f'secret:{tenant}/{canonical_project_name}/{oidc_name}'
iss = oidc_config.get('iss', self.arguments["zuul_root_url"])
payload = {
"iss": iss,
'sub': sub,
'build-uuid': self.arguments["zuul"]["build"],
'job-name': self.arguments['zuul']['job'],
'playbook': playbook.canonical_name_and_path,
'pipeline': self.arguments['zuul']['pipeline'],
'tenant': tenant,
'iat': iat,
'exp': exp,
}
custom_claims = oidc_config.get('claims', {})
for key, value in custom_claims.items():
# custom claims should not overwrite the default claims
if key not in payload:
payload[key] = value
token = jwt.encode(
payload, private_secrets_key, algorithm=algorithm,
headers={"kid": f"{algorithm}-{version}"})
oidc_tokens[oidc_name] = token
oidc_tokens_content = yaml.ansible_unsafe_dump(
yaml.mark_strings_unsafe(oidc_tokens), default_flow_style=False)
if playbook.secrets_content:
playbook.secrets_content += oidc_tokens_content
else:
playbook.secrets_content = oidc_tokens_content
def runAnsiblePlaybook(self, playbook, timeout, ansible_version,
success=None, phase=None, index=None,
will_retry=None):
if playbook.secrets_oidc:
self._generateOidcTokens(playbook)
if playbook.trusted or playbook.secrets_content:
self.writeInventory(playbook, self.frozen_hostvars)
else:

View File

@ -40,7 +40,9 @@ from zuul import change_matcher
from zuul.exceptions import (
SEVERITY_ERROR,
SEVERITY_WARNING,
OIDCIssuerNotAllowedError,
LabelForbiddenError,
MaxOIDCTTLError,
MaxTimeoutError,
NodesetNotFoundError,
ProjectNotFoundError,
@ -2761,6 +2763,9 @@ class Secret(ConfigObject):
# is named 'secret_data' to make it easy to search for and
# spot where it is directly used.
self.secret_data = {}
# This attribute stores the oidc token configuration for the
# oidc secrets. Mutually exclusive with secret_data.
self.secret_oidc = {}
def __ne__(self, other):
return not self.__eq__(other)
@ -2769,7 +2774,8 @@ class Secret(ConfigObject):
if not isinstance(other, Secret):
return False
return (self.name == other.name and
self.secret_data == other.secret_data)
self.secret_data == other.secret_data and
self.secret_oidc == other.secret_oidc)
def __repr__(self):
return '<Secret %s>' % (self.name,)
@ -2801,8 +2807,26 @@ class Secret(ConfigObject):
r.secret_data = self._decrypt(private_key, self.secret_data)
return r
def serialize(self):
return yaml.encrypted_dump(self.secret_data, default_flow_style=False)
def serialize(self, layout):
# The output of this method is used by the executor
# Set the ttl for this tenant
if self.secret_oidc:
secret_oidc = self.secret_oidc.copy()
# We previously checked that it's not greater than the tenant
# max.
secret_oidc['ttl'] = secret_oidc.get(
'ttl', layout.tenant.default_oidc_ttl)
else:
secret_oidc = {}
if COMPONENT_REGISTRY.model_api >= 35:
data = {
"secret_data": self.secret_data,
"secret_oidc": secret_oidc
}
else:
data = self.secret_data
return yaml.encrypted_dump(data, default_flow_style=False)
class SecretUse(ConfigObject):
@ -3025,7 +3049,7 @@ class PlaybookContext(ConfigObject):
for secret_use in self.secrets:
secret = layout.secrets.get(secret_use.name)
secret_name = secret_use.alias
encrypted_secret_data = secret.serialize()
encrypted_secret_data = secret.serialize(layout)
# Use *our* project, not the secret's, because we want to decrypt
# with *our* key.
project = layout.tenant.getProject(
@ -4573,7 +4597,7 @@ class Job(ConfigObject):
raise SecretNotFoundError(
"Secret %s not found" % (secret_use.name,))
secret_name = secret_use.alias
encrypted_secret_data = secret.serialize()
encrypted_secret_data = secret.serialize(layout)
# Use the other project, not the secret's, because we
# want to decrypt with the other project's key key.
connection_name = other.source_context.project_connection_name
@ -9604,7 +9628,18 @@ class Layout(object):
self._checkAddNodeset(nodeset)
self._addIdenticalObject('Nodeset', self.nodesets, nodeset)
def _checkAddSecret(self, secret):
if secret.secret_oidc:
ttl = secret.secret_oidc.get('ttl', self.tenant.default_oidc_ttl)
if int(ttl) > self.tenant.max_oidc_ttl:
raise MaxOIDCTTLError(secret, self.tenant)
iss = secret.secret_oidc.get('iss')
if iss and iss not in self.tenant.allowed_oidc_issuers:
raise OIDCIssuerNotAllowedError(secret, iss)
def addSecret(self, secret):
self._checkAddSecret(secret)
self._addIdenticalObject('Secret', self.secrets, secret)
def addSemaphore(self, semaphore):
@ -10169,6 +10204,8 @@ class Tenant(object):
self.max_nodes_per_job = 5
self.max_job_timeout = 10800
self.max_oidc_ttl = 10800
self.default_oidc_ttl = 3600
self.allowed_oidc_issuers = []
self.max_changes_per_pipeline = None
self.max_dependencies = None
self.exclude_unprotected_branches = False

View File

@ -14,4 +14,4 @@
# When making ZK schema changes, increment this and add a record to
# doc/source/developer/model-changelog.rst
MODEL_API = 34
MODEL_API = 35