Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions certs/src/enroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,25 @@ pub fn verify_csr(csr_pem: &str) -> Result<String, EnrollError> {
if csr_pem.len() > MAX_CSR_PEM_BYTES {
return Err(EnrollError::TooLarge);
}
one_request_block(csr_pem)?;
let public_key_der = backend::csr_spki_der(csr_pem).map_err(map_backend_error)?;
Ok(hex(&backend::sha256(&public_key_der)))
}

/// The PEM labels a request may carry.
const REQUEST_LABELS: [&str; 2] = ["CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST"];

/// Exactly one PEM block, and it is a request. A bundle that also carries a
/// private key or a certificate is refused before any backend reads it, since
/// one backend skips non-request blocks and would accept the bundle.
fn one_request_block(csr_pem: &str) -> Result<(), EnrollError> {
let blocks = pem::parse_many(csr_pem).map_err(|_bad| EnrollError::Malformed)?;
match blocks.as_slice() {
[block] if REQUEST_LABELS.contains(&block.tag()) => Ok(()),
_other => Err(EnrollError::Malformed),
}
}

/// Whether `key_pem` is the private key for `cert_pem`.
#[must_use]
pub fn key_matches_cert(key_pem: &str, cert_pem: &str) -> bool {
Expand Down Expand Up @@ -567,4 +582,15 @@ mod tests {
.verify_signature(Some(ca_cert.public_key()))
.expect("issued cert must verify against the CA");
}

#[test]
fn a_request_bundled_with_a_key_is_refused_before_any_backend_reads_it() {
let csr = crate::generate_csr("east").expect("csr");
let bundled = format!("{}{}", csr.key_pem.as_str(), csr.csr_pem);
assert!(matches!(verify_csr(&bundled), Err(EnrollError::Malformed)));
let twice = format!("{}{}", csr.csr_pem, csr.csr_pem);
assert!(matches!(verify_csr(&twice), Err(EnrollError::Malformed)));
let legacy = csr.csr_pem.replace("CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST");
assert!(verify_csr(&legacy).is_ok(), "the legacy label is still a request");
}
}
5 changes: 5 additions & 0 deletions charts/grid-enrollment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,13 @@ Each override takes a Secret reference, so no key material is inlined in values.
| `db.type` | `builtin` Postgres | `external` + `db.external.connectionUrlSecretRef` |
| `db.builtin.pvcAnnotations` | none | annotations on the builtin DB PVC, which holds every site record; under Argo CD set `argocd.argoproj.io/sync-options: Prune=false,Delete=false` |
| `enrollment.authz` | `kube` (SAR, needs the sar-feature image) | `local` (standalone grid-admin token table) |
| `enrollment.csrRecord.enabled` | `true` under `authz=kube`: each enrollment is recorded as a `CertificateSigningRequest`, approved and completed by the service, off the request path. The API server deletes an issued request about an hour after approval; the API server's audit log keeps the history when its policy covers these writes at `Request` level or above with a persistent backend. The service's own enrollment record is separate and always kept | `false` keeps no Kubernetes record; also the setting for Kubernetes 1.26 to 1.29, which lack the policy API |
| `enrollment.csrRecord.signerName` | `grid.praxis.fast/site` | one per enrollment service on a cluster, since the policy admits only this release's service for it |
| `enrollment.csrRecord.policy.enabled` | `true`: two `ValidatingAdmissionPolicies`, one letting only the service file under the signer name, one confining the service to that name (Kubernetes 1.30). Required while the record is on | `false` is refused; turn the record off on clusters without the v1 policy API |
Comment thread
hexfusion marked this conversation as resolved.
Resolved
| `enrollment.gridAdminTokens.existingSecretRef` | generated (local authz) | pre-created token Secret (name:token lines) |

The installing principal must itself hold `approve` and `sign` on the signer name, or RBAC escalation prevention refuses the record roles.

## Site invites

Each `invites` entry (`siteName`, `gridNetworkRef`, optional `expiresInSecs` up to 604800) has a post-install and post-upgrade Job mint a one-time site token into Secret `grid-invite-<siteName>` (key `token`). The Job skips sites whose Secret already exists, so an upgrade mints only for new sites. Invites need `enrollment.authz=kube`. Before Helm 3.19, a failed invite run leaves its hook RBAC in place until the next run. [Site Enrollment](../../docs/installation/enrollment.md#invite-a-site-on-the-hub) covers delivery and revocation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{{- include "grid-enrollment.normalize" . }}
{{- if and (eq .Values.enrollment.authz "kube") .Values.enrollment.csrRecord.enabled }}
{{- if not .Values.enrollment.csrRecord.policy.enabled }}
{{- fail "enrollment.csrRecord needs enrollment.csrRecord.policy: without it the record roles grant cluster-wide create on CertificateSigningRequests. On a cluster without admissionregistration.k8s.io/v1 set enrollment.csrRecord.enabled=false" }}
{{- end }}
{{- $signer := .Values.enrollment.csrRecord.signerName }}
{{- $sa := printf "system:serviceaccount:%s:%s" .Release.Namespace (include "grid-enrollment.serviceAccountName" .) }}
{{- $name := printf "%s-%s-csr-record" (include "grid-enrollment.fullname" .) .Release.Namespace }}
{{/*
Two policies, cluster-scoped so their names carry the namespace.

The first lets only this release's service file a request under its signer
name. Without it any principal with create on CertificateSigningRequests could
put a request in the record under the grid's name. It stops ordinary CSR
creators and accidents; a principal that can act as the service account, by
impersonation, a minted token or exec into the pod, is the service.

The second confines the service account to its own signer name on every write
it can make, so a compromised service cannot file, approve or complete
requests under any other signer. spec is immutable, so on an update
object.spec is the stored value.
*/}}
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: {{ $name }}
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["certificates.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["certificatesigningrequests"]
matchConditions:
- name: grid-signer
expression: {{ printf "object.spec.signerName == %q" $signer | quote }}
validations:
- expression: {{ printf "request.userInfo.username == %q" $sa | quote }}
message: "only the grid enrollment service may file a request under a grid signer name"
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: {{ $name }}
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
spec:
policyName: {{ $name }}
validationActions: [Deny]
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: {{ $name }}-confine
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["certificates.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["certificatesigningrequests", "certificatesigningrequests/approval", "certificatesigningrequests/status"]
matchConditions:
- name: grid-enrollment-service
expression: {{ printf "request.userInfo.username == %q" $sa | quote }}
validations:
- expression: {{ printf "object.spec.signerName == %q" $signer | quote }}
message: "the grid enrollment service may only touch requests under its own signer name"
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: {{ $name }}-confine
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
spec:
policyName: {{ $name }}-confine
validationActions: [Deny]
{{- end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{{- include "grid-enrollment.normalize" . }}
{{- if and (eq .Values.enrollment.authz "kube") .Values.enrollment.csrRecord.enabled }}
{{- $signer := .Values.enrollment.csrRecord.signerName }}
{{/*
The service records each enrollment as a CertificateSigningRequest. Two roles:
filing needs create plus the status subresource and the sign verb on the
signer; approving needs the approval subresource and the approve verb. They are
separate so a later release can move approval to people without rewriting the
filing role. Cluster-scoped, so the names carry the namespace. The installing
principal must itself hold approve and sign on the signer, or RBAC escalation
prevention refuses these roles.
*/}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-file
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
rules:
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests"]
verbs: ["create"]
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests/status"]
verbs: ["patch"]
- apiGroups: ["certificates.k8s.io"]
resources: ["signers"]
resourceNames: [{{ $signer | quote }}]
verbs: ["sign"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-approve
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
rules:
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests/approval"]
verbs: ["patch"]
- apiGroups: ["certificates.k8s.io"]
resources: ["signers"]
resourceNames: [{{ $signer | quote }}]
verbs: ["approve"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-file
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-file
subjects:
- kind: ServiceAccount
name: {{ include "grid-enrollment.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-approve
labels:
{{- include "grid-enrollment.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "grid-enrollment.fullname" . }}-{{ .Release.Namespace }}-csr-approve
subjects:
- kind: ServiceAccount
name: {{ include "grid-enrollment.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
6 changes: 6 additions & 0 deletions charts/grid-enrollment/templates/enrollment/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ spec:
fieldPath: metadata.namespace
- name: ENROLLMENT_TOKEN_AUDIENCE
value: {{ .Values.enrollment.tokenAudience | quote }}
{{- if .Values.enrollment.csrRecord.enabled }}
- name: ENROLLMENT_CSR_RECORD
value: "true"
- name: ENROLLMENT_CSR_SIGNER_NAME
value: {{ .Values.enrollment.csrRecord.signerName | quote }}
{{- end }}
{{- end }}
{{- with .Values.hubSite.name }}
- name: ENROLLMENT_RESERVED_SITES
Expand Down
135 changes: 135 additions & 0 deletions charts/grid-enrollment/tests/csr_record_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
suite: enrollment record as CertificateSigningRequest
templates:
- templates/enrollment/csr-record-rbac.yaml
- templates/enrollment/csr-record-policy.yaml
- templates/enrollment/deployment.yaml
tests:
- it: splits filing from approving, each scoped to the signer name
template: templates/enrollment/csr-record-rbac.yaml
asserts:
- hasDocuments:
count: 4
- documentIndex: 0
equal:
path: metadata.name
value: RELEASE-NAME-grid-enrollment-NAMESPACE-csr-file
- documentIndex: 0
equal:
path: rules
value:
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests"]
verbs: ["create"]
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests/status"]
verbs: ["patch"]
- apiGroups: ["certificates.k8s.io"]
resources: ["signers"]
resourceNames: ["grid.praxis.fast/site"]
verbs: ["sign"]
- documentIndex: 1
equal:
path: metadata.name
value: RELEASE-NAME-grid-enrollment-NAMESPACE-csr-approve
- documentIndex: 1
equal:
path: rules
value:
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests/approval"]
verbs: ["patch"]
- apiGroups: ["certificates.k8s.io"]
resources: ["signers"]
resourceNames: ["grid.praxis.fast/site"]
verbs: ["approve"]

- it: binds both roles to the service account
template: templates/enrollment/csr-record-rbac.yaml
documentSelector:
path: kind
value: ClusterRoleBinding
matchMany: true
asserts:
- equal:
path: subjects[0].name
value: RELEASE-NAME-grid-enrollment
- equal:
path: subjects[0].namespace
value: NAMESPACE

- it: scopes the signer verbs to a configured signer name
set:
enrollment.csrRecord.signerName: example.com/west
template: templates/enrollment/csr-record-rbac.yaml
documentSelector:
path: kind
value: ClusterRole
matchMany: true
asserts:
- equal:
path: rules[-1].resourceNames
value: ["example.com/west"]

- it: lets only the service file under the signer name, and confines the service to it
template: templates/enrollment/csr-record-policy.yaml
asserts:
- hasDocuments:
count: 4
- documentIndex: 0
equal:
path: spec.matchConditions[0].expression
value: "object.spec.signerName == \"grid.praxis.fast/site\""
- documentIndex: 0
equal:
path: spec.validations[0].expression
value: "request.userInfo.username == \"system:serviceaccount:NAMESPACE:RELEASE-NAME-grid-enrollment\""
- documentIndex: 0
equal:
path: spec.failurePolicy
value: Fail
- documentIndex: 2
equal:
path: spec.matchConditions[0].expression
value: "request.userInfo.username == \"system:serviceaccount:NAMESPACE:RELEASE-NAME-grid-enrollment\""
- documentIndex: 2
equal:
path: spec.validations[0].expression
value: "object.spec.signerName == \"grid.praxis.fast/site\""
- documentIndex: 2
equal:
path: spec.matchConstraints.resourceRules[0].operations
value: ["CREATE", "UPDATE"]

- it: turns the record on in the service with its signer name
template: templates/enrollment/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="ENROLLMENT_CSR_RECORD")].value
value: "true"
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="ENROLLMENT_CSR_SIGNER_NAME")].value
value: grid.praxis.fast/site

- it: renders nothing under local authz
set:
enrollment.authz: local
template: templates/enrollment/csr-record-rbac.yaml
asserts:
- hasDocuments:
count: 0

- it: renders nothing when the record is off
set:
enrollment.csrRecord.enabled: false
template: templates/enrollment/csr-record-rbac.yaml
asserts:
- hasDocuments:
count: 0

- it: refuses the record without the policy
set:
enrollment.csrRecord.policy.enabled: false
template: templates/enrollment/csr-record-policy.yaml
asserts:
- failedTemplate:
errorPattern: needs enrollment.csrRecord.policy
Loading
Loading