Skip to content

Commit c0b6696

Browse files
authored
Merge pull request #19 from spryker-sdk/sso-jenkins
Jenkins. SSO enablement
2 parents 1f61c30 + 3c38fd0 commit c0b6696

8 files changed

Lines changed: 330 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- "spryker/jenkins-boilerplate:2.504.1"
2222
platforms: [ "linux/amd64", "linux/arm64" ]
2323

24-
- image: "Dockerfile"
24+
- image: "Dockerfile.sso"
2525
jenkins_version: "2.516.3"
2626
tags:
2727
- "spryker/jenkins-boilerplate:2.516.3"

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ RUN bash -c "jenkins.sh &" && sleep 100 && \
88
FROM jenkins/jenkins:${JENKINS_VERSION} AS jenkins
99
ARG NEWRELIC_PLUGIN_VERSION=1.0.5
1010
COPY plugins/${JENKINS_VERSION}/plugins.txt /tmp/plugins.txt
11+
1112
USER root
1213
RUN curl -sSL https://github.com/newrelic/nr-jenkins-plugin/releases/download/v${NEWRELIC_PLUGIN_VERSION}/nr-jenkins-${NEWRELIC_PLUGIN_VERSION}.zip -o /tmp/newrelic.zip && \
1314
cd /tmp && unzip /tmp/newrelic.zip && \

Dockerfile.sso

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
ARG JENKINS_VERSION
2+
3+
FROM jenkins/jenkins:${JENKINS_VERSION} AS jenkins_cli
4+
USER root
5+
RUN bash -c "jenkins.sh &" && sleep 100 && \
6+
curl http://localhost:8080/jnlpJars/jenkins-cli.jar -o /usr/share/jenkins/jenkins-cli.jar
7+
8+
FROM jenkins/jenkins:${JENKINS_VERSION} AS jenkins
9+
ARG NEWRELIC_PLUGIN_VERSION=1.0.5
10+
COPY plugins/${JENKINS_VERSION}/plugins.txt /tmp/plugins.txt
11+
COPY plugins/${JENKINS_VERSION}/casc.yaml /usr/share/jenkins/ref/casc.yaml
12+
COPY plugins/${JENKINS_VERSION}/bootstrap_token.groovy /usr/share/jenkins/ref/init.groovy.d/bootstrap_token.groovy
13+
14+
USER root
15+
RUN curl -sSL https://github.com/newrelic/nr-jenkins-plugin/releases/download/v${NEWRELIC_PLUGIN_VERSION}/nr-jenkins-${NEWRELIC_PLUGIN_VERSION}.zip -o /tmp/newrelic.zip && \
16+
cd /tmp && unzip /tmp/newrelic.zip && \
17+
ls -l /tmp/ && \
18+
jenkins-plugin-cli --plugin-file /tmp/plugins.txt && bash -c "jenkins.sh &" && \
19+
cp /tmp/nr-jenkins-plugin/new-relic.hpi /usr/share/jenkins/ref/plugins/
20+
21+
# As we just use the artefacts a smaller image can be used as final target
22+
FROM alpine:latest
23+
24+
COPY --from=jenkins /usr/share/jenkins/ref/plugins /usr/share/jenkins/ref/plugins
25+
COPY --from=jenkins /usr/share/jenkins/ref/casc.yaml /usr/share/jenkins/ref/casc.yaml
26+
COPY --from=jenkins /usr/share/jenkins/ref/init.groovy.d/bootstrap_token.groovy /usr/share/jenkins/ref/init.groovy.d/bootstrap_token.groovy
27+
COPY --from=jenkins /usr/share/jenkins/jenkins.war /usr/share/jenkins/jenkins.war
28+
COPY --from=jenkins_cli /usr/share/jenkins/jenkins-cli.jar /usr/share/jenkins/jenkins-cli.jar

plugins/2.492.3/plugins.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ token-macro:444.v52de7e9c573d
55
snakeyaml-api:2.3-125.v4d77857a_b_402
66
jackson2-api:2.18.3-402.v74c4eb_f122b_2
77
variant:70.va_d9f17f859e0
8-
workflow-step-api:704.ve4f0967e98fa_
8+
workflow-step-api:710.v3e456cc85233
99
configuration-as-code:1958.vddc0d369b_e16
1010
throttle-concurrents:2.16
1111
PrioritySorter:5.3.0

plugins/2.504.1/plugins.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ token-macro:444.v52de7e9c573d
55
snakeyaml-api:2.3-125.v4d77857a_b_402
66
jackson2-api:2.18.3-402.v74c4eb_f122b_2
77
variant:70.va_d9f17f859e0
8-
workflow-step-api:704.ve4f0967e98fa_
8+
workflow-step-api:710.v3e456cc85233
99
configuration-as-code:1958.vddc0d369b_e16
1010
throttle-concurrents:2.16
1111
PrioritySorter:5.3.0
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import jenkins.model.Jenkins
2+
import hudson.model.User
3+
import java.nio.file.*
4+
import java.nio.charset.StandardCharsets
5+
import groovy.transform.Field
6+
7+
// ===== SSO enablement check =====
8+
// Exit early if SSO is disabled via SPRYKER_SCHEDULER_SSO_ENABLED environment variable
9+
if (System.getenv('SPRYKER_SCHEDULER_SSO_ENABLED') != 'true') {
10+
println "[bootstrap] SSO is disabled (SPRYKER_SCHEDULER_SSO_ENABLED != true); skipping token generation"
11+
return
12+
}
13+
14+
// ===== env / dynamic path =====
15+
@Field final String REGION = System.getenv('AWS_REGION') ?: 'unknown-region'
16+
@Field final String PROJECT = System.getenv('SPRYKER_PROJECT_NAME') ?: 'unknown-project'
17+
@Field final List<String> SSM_PARAMS = [
18+
"/${PROJECT}/custom-secrets/SPRYKER_SCHEDULER_PASSWORD",
19+
]
20+
21+
// Jenkins user to mint token for
22+
@Field final String USERNAME = System.getenv('SPRYKER_SCHEDULER_USER') ?: 'svc-spryker'
23+
@Field final String LABEL = "bootstrap-" + System.currentTimeMillis()
24+
25+
// Jenkins home as a Path
26+
// [CHANGED] Always use the *actual running* Jenkins root dir; no env/default fallback.
27+
@Field final Path HOME = Jenkins.get().getRootDir().toPath() // [CHANGED]
28+
@Field final Path OUT_DIR = HOME.resolve('secrets').resolve('bootstrap')
29+
@Field final Path OUT_FILE = OUT_DIR.resolve("${USERNAME}.token")
30+
Files.createDirectories(OUT_DIR)
31+
32+
// ---- marker to signal completion to entrypoint ----
33+
@Field final Path MARKER_DIR = OUT_DIR
34+
@Field final Path MARKER_FILE = MARKER_DIR.resolve(".token_ready")
35+
36+
def writeMarker() {
37+
try {
38+
Files.createDirectories(this.@MARKER_DIR)
39+
Files.write(
40+
this.@MARKER_FILE,
41+
("ready@" + new Date().toString() + "\n").getBytes("UTF-8"),
42+
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING
43+
)
44+
try {
45+
def perms = [
46+
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
47+
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE
48+
] as Set
49+
Files.setPosixFilePermissions(this.@MARKER_FILE, perms)
50+
} catch (Throwable ignore) {}
51+
println "[bootstrap] wrote marker: ${this.@MARKER_FILE}"
52+
} catch (Throwable t) {
53+
println "[bootstrap] WARNING: failed to write marker ${this.@MARKER_FILE}: ${t.message}"
54+
}
55+
}
56+
57+
// ---- resolve ApiTokenProperty class safely (handles package differences)
58+
Class apiTokenPropertyClass
59+
try {
60+
apiTokenPropertyClass = Class.forName('jenkins.security.apitoken.ApiTokenProperty')
61+
} catch (Throwable ignore) {
62+
apiTokenPropertyClass = Class.forName('jenkins.security.ApiTokenProperty') // older cores
63+
}
64+
65+
// ===== helpers =====
66+
def sh(Map<String,String> env, String cmd) {
67+
def pb = new ProcessBuilder(["bash","-lc", cmd]).redirectErrorStream(true)
68+
if (env) pb.environment().putAll(env)
69+
def p = pb.start()
70+
String out = p.inputStream.getText(StandardCharsets.UTF_8.name()).trim()
71+
int rc = p.waitFor()
72+
[rc: rc, out: out]
73+
}
74+
75+
def writeTokenFile(Path outFile, String uuid, String value) {
76+
String content = (uuid ? "tokenUuid=${uuid}\n" : "") + "tokenValue=${value}\n"
77+
Files.write(outFile, content.getBytes('UTF-8'),
78+
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
79+
try {
80+
def perms = [
81+
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
82+
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE
83+
] as Set
84+
Files.setPosixFilePermissions(outFile, perms)
85+
} catch (Throwable ignore) {}
86+
}
87+
88+
// --- DEBUG HELPERS (logs only; no behavior change) ---
89+
def rightN(String s, int n) {
90+
if (s == null) return ""
91+
int len = s.length()
92+
int start = Math.max(0, len - n)
93+
return s.substring(start, len)
94+
}
95+
96+
def mask(String s, int head=6, int tail=4) {
97+
if (!s) return "(null)"
98+
if (s.length() <= head + tail) return s
99+
return s.take(head) + "" + rightN(s, tail)
100+
}
101+
102+
def dbgToken(String where, String uuid, String value) {
103+
println "[debug] token@${where}: len=${value?.size() ?: 0}, masked=${mask(value)}, uuid=${uuid ?: '(none)'}"
104+
try {
105+
def basic = "${USERNAME}:${value ?: ''}".bytes.encodeBase64().toString()
106+
println "[debug] basicHeaderSample: Authorization: Basic " + mask(basic, 8, 6)
107+
} catch (Throwable ignore) {}
108+
}
109+
110+
// [NEW] Validate token against the running Jenkins before reuse
111+
def isTokenValid(String user, String token) { // [NEW]
112+
if (!token) return false // [NEW]
113+
try { // [NEW]
114+
String base = System.getenv('JENKINS_URL') ?: (Jenkins.get().getRootUrl() ?: 'http://127.0.0.1:8080/')
115+
base = base.replaceAll('/+$','')
116+
def cmd = """curl -s -o /dev/null -w '%{http_code}' \
117+
-H "Authorization: Basic ${("${user}:${token}").bytes.encodeBase64().toString()}" \
118+
"${base}/whoAmI/api/json" """
119+
def r = sh([:], cmd)
120+
return r.rc == 0 && r.out == '200'
121+
} catch (Throwable t) {
122+
return false
123+
}
124+
} // [NEW]
125+
126+
// publish to all target params
127+
def publishToSSM(String value) {
128+
if (!value) return
129+
final String region = this.@REGION
130+
final String project = this.@PROJECT
131+
if (!region || project == 'unknown-project') {
132+
println "[bootstrap] Skipping SSM (REGION/PROJECT not set): REGION=${region} PROJECT=${project}"
133+
return
134+
}
135+
this.@SSM_PARAMS.each { String param ->
136+
def r = sh([TOKEN_VALUE: value, AWS_REGION: region],
137+
'aws ssm put-parameter --region "$AWS_REGION" --name "'+param+'" --type SecureString --overwrite --value "$TOKEN_VALUE"')
138+
println "[bootstrap] SSM put-parameter rc=${r.rc} -> ${param}"
139+
if (r.rc != 0) {
140+
println "[bootstrap] WARNING: aws cli returned non-zero; output:\n${r.out}"
141+
}
142+
}
143+
}
144+
145+
// ===== main flow =====
146+
String tokenValue
147+
String tokenUuid = null
148+
149+
// If local file already exists, validate and reuse if still good; otherwise re-mint
150+
if (Files.exists(OUT_FILE)) {
151+
def text = Files.readString(OUT_FILE, StandardCharsets.UTF_8)
152+
def m = (text =~ /(?m)^tokenValue=(.+)$/)
153+
def mu = (text =~ /(?m)^tokenUuid=(.+)$/)
154+
if (m.find()) {
155+
tokenValue = m.group(1).trim()
156+
tokenUuid = (mu.find() ? mu.group(1).trim() : null)
157+
dbgToken("disk", tokenUuid, tokenValue)
158+
159+
// [NEW] validate-before-reuse (auto self-heal if secrets rotated)
160+
if (isTokenValid(USERNAME, tokenValue)) { // [NEW]
161+
println "[bootstrap] token file exists and is valid; skipping generation" // [NEW]
162+
publishToSSM(tokenValue) // [NEW]
163+
writeMarker() // [NEW]
164+
return // [NEW]
165+
} else { // [NEW]
166+
println "[bootstrap] existing token is NOT valid; minting a new one" // [NEW]
167+
}
168+
} else {
169+
println "[bootstrap] token file exists but tokenValue not found; will generate a new token"
170+
}
171+
}
172+
173+
// Create or load user
174+
def u = User.getById(USERNAME, true)
175+
176+
// Ensure the ApiTokenProperty exists
177+
def p = u.getProperty(apiTokenPropertyClass as Class)
178+
if (p == null) {
179+
p = apiTokenPropertyClass.getDeclaredConstructor().newInstance()
180+
u.addProperty(p)
181+
u.save()
182+
}
183+
184+
// ===== generate & extract token (getter-or-field tolerant) =====
185+
def tokenStore = p.getClass().getMethod('getTokenStore').invoke(p)
186+
def t = tokenStore.getClass().getMethod('generateNewToken', String).invoke(tokenStore, LABEL)
187+
188+
// Helper: try getter, Groovy property, then direct field
189+
def readProp = { obj, List<String> names ->
190+
for (String n : names) {
191+
try { def m = obj.getClass().getMethod(n); def v = m.invoke(obj); if (v != null) return v.toString() } catch (Throwable ignore) {}
192+
try { def v = obj."$n"; if (v != null) return v.toString() } catch (Throwable ignore) {}
193+
try {
194+
def fName = n.startsWith('get') ? n.substring(3,4).toLowerCase() + n.substring(4) : n
195+
def f = obj.getClass().getDeclaredField(fName); f.setAccessible(true)
196+
def v = f.get(obj); if (v != null) return v.toString()
197+
} catch (Throwable ignore) {}
198+
}
199+
return null
200+
}
201+
202+
tokenValue = readProp(t, ['getPlainValue','plainValue','getValue','value'])
203+
tokenUuid = readProp(t, ['getTokenUuid','tokenUuid','getUuid','uuid'])
204+
205+
if (!tokenValue) {
206+
try {
207+
def legacy = p.getClass().getMethod('getApiToken').invoke(p)
208+
if (legacy) tokenValue = legacy.toString()
209+
} catch (Throwable ignore) {}
210+
}
211+
212+
dbgToken("generated", tokenUuid, tokenValue)
213+
214+
// [NEW] Ensure token store changes are flushed to disk
215+
try { u.save() } catch (Throwable ignore) {} // [NEW]
216+
217+
// Write local file and update SSM
218+
writeTokenFile(OUT_FILE, tokenUuid, tokenValue)
219+
println "[bootstrap] generated API token for ${USERNAME}; wrote ${OUT_FILE}"
220+
println "[bootstrap] updating SSM params: ${this.@SSM_PARAMS.join(', ')}"
221+
publishToSSM(tokenValue)
222+
223+
// mark completion so entrypoint can proceed
224+
writeMarker()

plugins/2.516.3/casc.yaml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
unclassified:
2+
location:
3+
url: "${JENKINS_URL}"
4+
5+
jenkins:
6+
projectNamingStrategy:
7+
roleBasedProjectNamingStrategy:
8+
forceExistingJobs: false
9+
10+
securityRealm:
11+
oic:
12+
clientId: "${SPRYKER_SCHEDULER_OIDC_CLIENT_ID}"
13+
clientSecret: "${SPRYKER_SCHEDULER_OIDC_CLIENT_SECRET}"
14+
userNameField: "email"
15+
emailFieldName: "email"
16+
fullNameFieldName: "name"
17+
groupsFieldName: "groups"
18+
pkceEnabled: true
19+
sendScopesInTokenRequest: true
20+
serverConfiguration:
21+
wellKnown:
22+
wellKnownOpenIDConfigurationUrl: "${SPRYKER_SCHEDULER_OIDC_WELL_KNOWN}"
23+
scopesOverride: "openid profile email"
24+
25+
authorizationStrategy:
26+
roleBased:
27+
roles:
28+
global:
29+
- name: "admin"
30+
description: "Full administrative access"
31+
permissions:
32+
- "Overall/Read"
33+
- "Overall/Administer"
34+
entries:
35+
- group: "jenkins-admins"
36+
- user: "svc-spryker"
37+
38+
- name: "viewer"
39+
description: "Read-only access to view jobs and builds"
40+
permissions:
41+
- "Overall/Read"
42+
- "Job/Read"
43+
- "Job/ExtendedRead"
44+
- "Job/Discover"
45+
- "Run/Read"
46+
entries:
47+
- group: "jenkins-viewer"
48+
49+
- name: "developer"
50+
description: "Can build, view, and cancel jobs"
51+
permissions:
52+
- "Overall/Read"
53+
- "Job/Read"
54+
- "Job/Build"
55+
- "Job/Cancel"
56+
- "Run/Read"
57+
- "Run/Replay"
58+
entries:
59+
- group: "jenkins-developer"
60+
61+
- name: "bootstrap-config-runner"
62+
description: "Service role for CI/CD pipelines to configure jobs-as-code"
63+
permissions:
64+
- "Overall/Read"
65+
- "Job/Read"
66+
- "Job/ExtendedRead"
67+
- "Job/Discover"
68+
- "Job/Create"
69+
- "Job/Configure"
70+
- "Job/Delete"
71+
- "Job/Build"
72+
entries:
73+
- group: "jenkins-bootstrap-config-runner"

plugins/2.516.3/plugins.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ disable-job-button:1.3.vf55949267366
1414
jobConfigHistory:1356.ve360da_6c523a_
1515
role-strategy:840.v206ff7f7312e
1616
audit-trail:436.vc0d1e79fc5a_3
17+
oic-auth:4.524.v38858a_b_1c6a_4

0 commit comments

Comments
 (0)