Skip to content

Commit 73cd374

Browse files
Add server side encryption kmip tests
Issue: ZENKO-5241
1 parent 65ba61a commit 73cd374

6 files changed

Lines changed: 306 additions & 4 deletions

File tree

.github/scripts/end2end/configure-e2e-ctst.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,12 @@ kubectl run kafka-topics \
106106
kafka-topics.sh --create --topic $AZURE_ARCHIVE_STATUS_TOPIC_2_NV --partitions 10 --bootstrap-server $KAFKA_HOST_PORT --if-not-exists ; \
107107
kafka-topics.sh --create --topic $AZURE_ARCHIVE_STATUS_TOPIC_2_V --partitions 10 --bootstrap-server $KAFKA_HOST_PORT --if-not-exists ; \
108108
kafka-topics.sh --create --topic $AZURE_ARCHIVE_STATUS_TOPIC_2_S --partitions 10 --bootstrap-server $KAFKA_HOST_PORT --if-not-exists"
109+
110+
# KMIP mock setup
111+
# Deploy PyKMIP server (infra only, does NOT patch the CR).
112+
# The CR is patched later, after file-backend SSE tests have run.
113+
if ! kubectl get deployment pykmip &>/dev/null; then
114+
bash "$(dirname "$0")/../mocks/setup-kmip.sh"
115+
else
116+
echo "PyKMIP deployment already exists, skipping setup-kmip.sh"
117+
fi
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/bin/bash
2+
# setup-kmip.sh — Deploy PyKMIP mock server for KMIP SSE testing.
3+
# Idempotent
4+
#
5+
# Deploys PyKMIP infra (certs, pod, service). The Zenko CR is patched
6+
# by the CTST Before hook when @ServerSideEncryptionKmip tests start.
7+
8+
set -euo pipefail
9+
10+
ZENKO_NAME="${ZENKO_NAME:-end2end}"
11+
NAMESPACE="${NAMESPACE:-default}"
12+
13+
# 1. Certs + secrets
14+
15+
if kubectl get secret "${ZENKO_NAME}-kmip-certs" -n "${NAMESPACE}" &>/dev/null; then
16+
echo "KMIP secrets already exist, skipping cert generation"
17+
else
18+
echo "Generating KMIP TLS certificates..."
19+
D=$(mktemp -d)
20+
trap 'rm -rf "$D"' EXIT
21+
22+
openssl genrsa -out "$D/ca.key" 4096 2>/dev/null
23+
openssl req -new -x509 -key "$D/ca.key" -out "$D/ca.pem" \
24+
-days 3650 -subj "/CN=KMIP-CA" 2>/dev/null
25+
26+
openssl genrsa -out "$D/server.key" 4096 2>/dev/null
27+
openssl req -new -key "$D/server.key" -out "$D/server.csr" \
28+
-subj "/CN=pykmip" 2>/dev/null
29+
openssl x509 -req -in "$D/server.csr" -CA "$D/ca.pem" -CAkey "$D/ca.key" \
30+
-CAcreateserial -out "$D/server.crt" -days 3650 \
31+
-extfile <(printf "subjectAltName=DNS:pykmip,DNS:pykmip.%s.svc.cluster.local" "$NAMESPACE") \
32+
2>/dev/null
33+
34+
openssl genrsa -out "$D/client.key" 4096 2>/dev/null
35+
openssl req -new -key "$D/client.key" -out "$D/client.csr" \
36+
-subj "/CN=cloudserver-client" 2>/dev/null
37+
openssl x509 -req -in "$D/client.csr" -CA "$D/ca.pem" -CAkey "$D/ca.key" \
38+
-CAcreateserial -out "$D/client.crt" -days 3650 \
39+
-extfile <(printf "extendedKeyUsage=clientAuth") 2>/dev/null
40+
41+
kubectl create secret generic "${ZENKO_NAME}-kmip-certs" \
42+
--from-file=ca.pem="$D/ca.pem" --from-file=cert.pem="$D/client.crt" \
43+
--from-file=key.pem="$D/client.key" \
44+
--dry-run=client -o yaml | kubectl apply -f -
45+
46+
kubectl create secret generic pykmip-server-certs \
47+
--from-file=ca.crt="$D/ca.pem" --from-file=server.crt="$D/server.crt" \
48+
--from-file=server.key="$D/server.key" \
49+
--dry-run=client -o yaml | kubectl apply -f -
50+
fi
51+
52+
# 2. PyKMIP startup script
53+
54+
kubectl create configmap pykmip-server-script --dry-run=client -o yaml \
55+
--from-literal=run_pykmip.py='
56+
import logging; from kmip.services.server import KmipServer
57+
logging.basicConfig(level=logging.INFO)
58+
server = KmipServer(hostname="0.0.0.0", port=5696,
59+
certificate_path="/certs/server.crt", key_path="/certs/server.key",
60+
ca_path="/certs/ca.crt", auth_suite="TLS1.2", config_path=None,
61+
enable_tls_client_auth=True, database_path="/tmp/pykmip.db")
62+
with server: server.serve()
63+
' | kubectl apply -f -
64+
65+
# 3. Deploy PyKMIP pod + service (inline YAML)
66+
67+
if ! kubectl get deployment pykmip -n "${NAMESPACE}" &>/dev/null; then
68+
kubectl apply -n "${NAMESPACE}" -f - <<'YAML'
69+
apiVersion: v1
70+
kind: Service
71+
metadata:
72+
name: pykmip
73+
spec:
74+
selector: { name: pykmip }
75+
ports: [{ name: kmip, port: 5696, targetPort: 5696 }]
76+
---
77+
apiVersion: apps/v1
78+
kind: Deployment
79+
metadata:
80+
name: pykmip
81+
labels: { name: pykmip }
82+
spec:
83+
replicas: 1
84+
selector:
85+
matchLabels: { name: pykmip }
86+
template:
87+
metadata:
88+
labels: { name: pykmip }
89+
spec:
90+
initContainers:
91+
- name: install
92+
image: docker.io/library/python:3.10-slim
93+
command: [pip, install, --target=/pykmip-libs, pykmip==0.10.0, -q]
94+
volumeMounts: [{ name: pykmip-libs, mountPath: /pykmip-libs }]
95+
containers:
96+
- name: pykmip
97+
image: docker.io/library/python:3.10-slim
98+
command: [python3, /scripts/run_pykmip.py]
99+
env: [{ name: PYTHONPATH, value: /pykmip-libs }]
100+
ports: [{ containerPort: 5696 }]
101+
readinessProbe:
102+
tcpSocket: { port: 5696 }
103+
initialDelaySeconds: 5
104+
periodSeconds: 3
105+
volumeMounts:
106+
- { name: certs, mountPath: /certs, readOnly: true }
107+
- { name: scripts, mountPath: /scripts, readOnly: true }
108+
- { name: pykmip-libs, mountPath: /pykmip-libs }
109+
volumes:
110+
- { name: certs, secret: { secretName: pykmip-server-certs } }
111+
- { name: scripts, configMap: { name: pykmip-server-script } }
112+
- { name: pykmip-libs, emptyDir: {} }
113+
YAML
114+
echo "Waiting for PyKMIP..."
115+
kubectl wait --for=condition=Available deployment/pykmip -n "${NAMESPACE}" --timeout=5m
116+
else
117+
echo "PyKMIP already deployed"
118+
fi
119+
120+
echo "PyKMIP infra ready"

solution/deps.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ vault:
130130
zenko-operator:
131131
sourceRegistry: ghcr.io/scality
132132
image: zenko-operator
133-
tag: v1.8.9
133+
tag: 07ca16ecedf59a56414c8ae522842fb78db93272
134134
envsubst: ZENKO_OPERATOR_TAG
135135
zookeeper:
136136
sourceRegistry: ghcr.io/adobe/zookeeper-operator

tests/ctst/common/hooks.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ITestCaseHookParameter,
77
} from '@cucumber/cucumber';
88
import Zenko from '../world/Zenko';
9-
import { CacheHelper, Identity } from 'cli-testing';
9+
import { CacheHelper, Identity, WorkCoordination } from 'cli-testing';
1010
import { prepareQuotaScenarios, teardownQuotaScenarios } from 'steps/quotas/quotas';
1111
import { prepareUtilizationScenarios } from 'steps/utilization/utilizationAPI';
1212
import { prepareMetricsScenarios } from './utils';
@@ -16,6 +16,7 @@ import { displayDebuggingInformation, preparePRA } from 'steps/pra';
1616
import {
1717
cleanupAccount,
1818
} from './utils';
19+
import { createKubeCustomObjectClient, waitForZenkoToStabilize } from 'steps/utils/kubernetes';
1920

2021
import 'cli-testing/hooks/KeycloakSetup';
2122
import 'cli-testing/hooks/Logger';
@@ -33,6 +34,7 @@ const noParallelRun = atMostOnePicklePerTag([
3334
'@AfterAll',
3435
'@PRA',
3536
'@ColdStorage',
37+
'@ServerSideEncryption',
3638
...replicationLockTags
3739
]);
3840

@@ -75,6 +77,65 @@ Before({ tags: '@PrepareStorageUsageReportingScenarios', timeout: 1200000 }, asy
7577
});
7678
});
7779

80+
Before({ tags: '@ServerSideEncryptionKmip', timeout: 15 * 60 * 1000 },
81+
// Patch the Zenko CR with KMIP configuration before running any KMIP-related tests
82+
async function (this: Zenko) {
83+
const lockName = `kmip-cr-patch-${process.ppid}`;
84+
await WorkCoordination.runOnceAcrossWorkers(
85+
{ lockName, logger: this.logger },
86+
async () => {
87+
const namespace = 'default';
88+
const zenkoName = 'end2end';
89+
const client = createKubeCustomObjectClient(this);
90+
const cr = await client.getNamespacedCustomObject({
91+
group: 'zenko.io',
92+
version: 'v1alpha2',
93+
namespace,
94+
plural: 'zenkos',
95+
name: zenkoName,
96+
}) as {
97+
spec?: {
98+
kms?: {
99+
kmip?: {
100+
providerName?: string;
101+
tlsAuth?: { tlsSecretName?: string };
102+
endpoints?: { host?: string; port?: number }[];
103+
};
104+
};
105+
};
106+
};
107+
const alreadyConfigured =
108+
cr?.spec?.kms?.kmip?.providerName === 'pykmip'
109+
&& cr?.spec?.kms?.kmip?.endpoints?.some(
110+
ep => ep.host === `pykmip.${namespace}.svc.cluster.local` && ep.port === 5696,
111+
);
112+
if (alreadyConfigured) {
113+
return;
114+
}
115+
116+
const kmipValue = {
117+
providerName: 'pykmip',
118+
tlsAuth: { tlsSecretName: `${zenkoName}-kmip-certs` },
119+
endpoints: [{
120+
host: `pykmip.${namespace}.svc.cluster.local`,
121+
port: 5696,
122+
}],
123+
};
124+
125+
await client.patchNamespacedCustomObject({
126+
group: 'zenko.io',
127+
version: 'v1alpha2',
128+
namespace,
129+
plural: 'zenkos',
130+
name: zenkoName,
131+
body: [{ op: 'add', path: '/spec/kms', value: { kmip: kmipValue } }],
132+
});
133+
await waitForZenkoToStabilize(this, true);
134+
},
135+
);
136+
},
137+
);
138+
78139
After(async function (this: Zenko, results) {
79140
// Reset any configuration set on the endpoint (ssl, port)
80141
CacheHelper.parameters.ssl = this.parameters.ssl;

tests/ctst/features/serverSideEncryption.feature

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,80 @@ Feature: Server Side Encryption
8585
Given a "Non versioned" bucket
8686
When the user gets bucket encryption
8787
Then it should fail with error "ServerSideEncryptionConfigurationNotFoundError"
88+
89+
# KMIP backend tests
90+
# These scenarios require a PyKMIP server to be deployed and Zenko to be
91+
# reconfigured with spec.kms.kmip before running. The previous @ServerSideEncryptionFileBackend
92+
# tests will not work once KMIP is configured on the ZENKO custom resource.
93+
94+
@2.14.0
95+
@PreMerge
96+
@ServerSideEncryption
97+
@ServerSideEncryptionKmip
98+
Scenario Outline: KMIP: should encrypt object when bucket encryption is <bucketAlgo> and object encryption is <objectAlgo>
99+
Given a "Non versioned" bucket
100+
And bucket encryption is set to "<bucketAlgo>" with key "<bucketKeyId>"
101+
Then the bucket encryption is verified for algorithm "<bucketAlgo>" and key "<bucketKeyId>"
102+
When an object "<objectName>" is uploaded with SSE algorithm "<objectAlgo>" and key "<objectKeyId>"
103+
Then the PutObject response should have SSE algorithm "<expectedAlgo>" and KMS key "<expectedKeyId>"
104+
Then the GetObject should return the uploaded body with SSE algorithm "<expectedAlgo>" and KMS key "<expectedKeyId>"
105+
106+
Examples: No bucket encryption
107+
| objectName | bucketAlgo | bucketKeyId | objectAlgo | objectKeyId | expectedAlgo | expectedKeyId |
108+
| kmip-none-none | | | | | | absent |
109+
| kmip-none-aes | | | AES256 | | AES256 | absent |
110+
111+
Examples: No bucket encryption, aws:kms
112+
| objectName | bucketAlgo | bucketKeyId | objectAlgo | objectKeyId | expectedAlgo | expectedKeyId |
113+
| kmip-none-kms | | | aws:kms | | aws:kms | generated |
114+
115+
Examples: Bucket AES256
116+
| objectName | bucketAlgo | bucketKeyId | objectAlgo | objectKeyId | expectedAlgo | expectedKeyId |
117+
| kmip-aes-none | AES256 | | | | AES256 | absent |
118+
| kmip-aes-aes | AES256 | | AES256 | | AES256 | absent |
119+
| kmip-aes-kms | AES256 | | aws:kms | | aws:kms | generated |
120+
121+
Examples: Bucket aws:kms (default key)
122+
| objectName | bucketAlgo | bucketKeyId | objectAlgo | objectKeyId | expectedAlgo | expectedKeyId |
123+
| kmip-kms-none | aws:kms | | | | aws:kms | generated |
124+
| kmip-kms-aes | aws:kms | | AES256 | | AES256 | absent |
125+
| kmip-kms-kms | aws:kms | | aws:kms | | aws:kms | generated |
126+
127+
@2.14.0
128+
@PreMerge
129+
@ServerSideEncryption
130+
@ServerSideEncryptionKmip
131+
Scenario: KMIP: DeleteBucketEncryption removes default encryption
132+
Given a "Non versioned" bucket
133+
And bucket encryption is set to "AES256" with key ""
134+
When an object "kmip-enc-obj" is uploaded with SSE algorithm "" and key ""
135+
Then the GetObject should return the uploaded body with SSE algorithm "AES256" and KMS key "absent"
136+
When the user deletes bucket encryption
137+
Then the GetObject should return the uploaded body with SSE algorithm "AES256" and KMS key "absent"
138+
When an object "kmip-plain-obj" is uploaded with SSE algorithm "" and key ""
139+
Then the GetObject should return the uploaded body with SSE algorithm "" and KMS key "absent"
140+
141+
@2.14.0
142+
@PreMerge
143+
@ServerSideEncryption
144+
@ServerSideEncryptionKmip
145+
Scenario Outline: KMIP: PutObject with invalid SSE parameters returns an error: <objectName>
146+
Given a "Non versioned" bucket
147+
When an object "<objectName>" is uploaded with SSE algorithm "<algo>" and key "<keyId>"
148+
Then it should fail with error "InvalidArgument"
149+
150+
Examples:
151+
| objectName | algo | keyId |
152+
| kmip-invalid-algo | INVALID_ALGO | |
153+
| kmip-aes-kms-err | AES256 | some-key |
154+
155+
@2.14.0
156+
@PreMerge
157+
@ServerSideEncryption
158+
@ServerSideEncryptionKmip
159+
Scenario: KMIP: objects in same bucket share the same KMIP master key
160+
Given a "Non versioned" bucket
161+
And bucket encryption is set to "aws:kms" with key ""
162+
When an object "kmip-shared-key-obj-a" is uploaded with SSE algorithm "" and key ""
163+
And an object "kmip-shared-key-obj-b" is uploaded with SSE algorithm "" and key ""
164+
Then objects "kmip-shared-key-obj-a" and "kmip-shared-key-obj-b" share the same KMS key

tests/ctst/steps/serverSideEncryption.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,15 @@ Then('the PutObject response should have SSE algorithm {string} and KMS key {str
158158
if (expectedKey === 'absent') {
159159
assert.strictEqual(result.sseKmsKeyId, undefined,
160160
`PutObject: SSEKMSKeyId should be absent, got "${result.sseKmsKeyId}"`);
161+
} else if (expectedKey === 'generated') {
162+
assert.ok(result.sseKmsKeyId, 'PutObject: SSEKMSKeyId should be present');
163+
// Accept either:
164+
// - 64-char hex (file/internal KMS backend)
165+
// - KMIP numeric ID or ARN: arn:scality:kms:external:kmip:<provider>:key/<n>
166+
const isFileBackendKey = /^[a-f0-9]{64}$/.test(result.sseKmsKeyId);
167+
const isKmipKey = /^(\d+|arn:scality:kms:external:kmip:[a-z0-9]+:key\/\d+)$/.test(result.sseKmsKeyId);
168+
assert.ok(isFileBackendKey || isKmipKey,
169+
`PutObject: expected a generated key (hex or KMIP), got "${result.sseKmsKeyId}"`);
161170
} else {
162171
assert.ok(result.sseKmsKeyId, 'PutObject: SSEKMSKeyId should be present');
163172
}
@@ -189,8 +198,13 @@ Then('the GetObject should return the uploaded body with SSE algorithm {string}
189198
`GetObject: SSEKMSKeyId should be absent, got "${resp.SSEKMSKeyId}"`);
190199
} else if (expectedKey === 'generated') {
191200
assert.ok(resp.SSEKMSKeyId, 'GetObject: SSEKMSKeyId should be present');
192-
assert.match(resp.SSEKMSKeyId, /^[a-f0-9]{64}$/,
193-
`GetObject: expected a generated hex key, got "${resp.SSEKMSKeyId}"`);
201+
// Accept either:
202+
// - 64-char hex (file/internal KMS backend)
203+
// - KMIP numeric ID or ARN: arn:scality:kms:external:kmip:<provider>:key/<n>
204+
const isFileBackendKey = /^[a-f0-9]{64}$/.test(resp.SSEKMSKeyId);
205+
const isKmipKey = /^(\d+|arn:scality:kms:external:kmip:[a-z0-9]+:key\/\d+)$/.test(resp.SSEKMSKeyId);
206+
assert.ok(isFileBackendKey || isKmipKey,
207+
`GetObject: expected a generated key (hex or KMIP), got "${resp.SSEKMSKeyId}"`);
194208
} else {
195209
assert.strictEqual(resp.SSEKMSKeyId, expectedKey,
196210
`GetObject: expected key "${expectedKey}", got "${resp.SSEKMSKeyId}"`);
@@ -208,3 +222,24 @@ Then('it should fail with error {string}',
208222
`Expected error "${expectedError}" but got: ${result.err}`);
209223
},
210224
);
225+
226+
Then('objects {string} and {string} share the same KMS key',
227+
async function (this: Zenko, objA: string, objB: string) {
228+
const bucket = this.getSaved<string>('bucketName');
229+
const client = buildS3Client();
230+
try {
231+
const [respA, respB] = await Promise.all([
232+
client.send(new GetObjectCommand({ Bucket: bucket, Key: objA })),
233+
client.send(new GetObjectCommand({ Bucket: bucket, Key: objB })),
234+
]);
235+
const keyA = respA.SSEKMSKeyId;
236+
const keyB = respB.SSEKMSKeyId;
237+
assert.ok(keyA, `Object "${objA}" has no SSEKMSKeyId`);
238+
assert.ok(keyB, `Object "${objB}" has no SSEKMSKeyId`);
239+
assert.strictEqual(keyA, keyB,
240+
`Objects in same bucket should share the same KMIP key; got "${keyA}" vs "${keyB}"`);
241+
} finally {
242+
client.destroy();
243+
}
244+
},
245+
);

0 commit comments

Comments
 (0)