bcgov / bcgov/cert-manager-proxy

Epic: Namespace-scoped certificate request & renewal via cert-manager-proxy

Open
#8 0 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
Go
Stars
1
Forks
0
Avg merge
38m
Merged PRs (30d)
6

Description

# Epic: Namespace-scoped certificate request & renewal via cert-manager-proxy

## Summary

`cert-manager-proxy` is the default, required way certificates are
requested, approved, and kept in sync with OpenShift Routes across all
namespaces on the cluster. It is the sole authorization and approval
decision point. A first-time request for a domain+issuer combination skips
straight to issuance if it matches a cluster-wide, admin-curated
pre-approved list; otherwise it requires a human via the proxy's own
review interface. Anything already approved once — whether via the
pre-approved list or manual sign-off — renews automatically through
cert-manager exactly as cert-manager already does it, unmodified. Route
TLS syncing and renewal are handled entirely by
`cert-manager/openshift-routes` and `cert-manager` themselves — the proxy
does not duplicate that logic, and does not run its own reconcile loop.

## Goals

1. **The proxy is the only path.** No direct `kubectl apply` of
`Certificate` objects, no unmediated use of `cert-manager/openshift-routes`.
2. **Namespace RBAC is the authorization model.** A user holding `admin`
or `edit` (or equivalent) in a namespace can request certificate
creation for a domain on a Route in that namespace, for a specific CA —
checked live per request via the caller's own Kubernetes identity, not
a shared credential.
3. **Pre-approved domain+issuer combinations skip manual review.** A
single, centrally-managed, cluster-wide allowlist (domain patterns
paired with the issuer(s) they're approved for) lets a first-time
request proceed immediately, with no human involved — checked by the
proxy itself before it would otherwise hold the request for review.
4. **Beyond that, first-time approval is manual; renewals are automatic.**
A first-time request for a combination *not* on the pre-approved list
requires a human to approve it through the proxy. Once a combination
has been approved once — either via the pre-approved list or manual
sign-off — cert-manager's normal renewal behavior (re-issuing before
expiry) proceeds without any further gating. The proxy is not in the
loop for renewals at all, because cert-manager isn't either; it just
does what it already does.
5. **Future renewals can be explicitly denied.** An authorized user can
revoke a previously-approved domain (pre-approved-list-based or
manually approved), permanently stopping cert-manager from renewing it
going forward.
6. **Requests are traceable to the requesting user.** Not necessarily via
cert-manager's own `CertificateRequest.spec.username` field (see
"Design decisions" — that field reflects cert-manager's own controller
identity in this architecture, not the original caller, and can't be
made to reflect the caller without larger tradeoffs). Instead: the
proxy stamps the requesting user's identity as a label/annotation on
the `Certificate`/`Route` it acts on.
7. **Route TLS stays in sync automatically**, including renewal, with no
further action from the requester once approved.

## Architecture

### Components

| Component | Role |
|---|---|
| `cert-manager-proxy` (this repo) | Sole authorization + first-approval decision point. Annotates the target Route once approved. Exposes a "deny future renewals" action. No Certificate creation, no reconcile loop of its own. |
| `cert-manager` | Issues and renews certificates via ACME/DNS-01. Runs with its **default** built-in approval behavior (see "Design decisions" — no `disableAutoApproval`, no approver-policy). |
| `cert-manager/openshift-routes` | Watches Route annotations, creates the `Certificate`, injects the result into `spec.tls`, and re-syncs on renewal — owns the entire post-approval lifecycle. |

`approver-policy` is **not** part of this architecture (see "Design
decisions" for why).

### Request flow — first-time domain/issuer combination

1. Caller sends a request to `cert-manager-proxy`: target namespace,
domain, Route reference, and requested CA/issuer.
2. Proxy authenticates the caller via Kubernetes `TokenReview`.
3. Proxy authorizes the request via `SubjectAccessReview`, checking the
caller holds `admin`/`edit` (or equivalent) in the target namespace.
4. Proxy checks whether this domain+issuer combination has been approved
before (see "Design decisions" for how "before" is determined). It
hasn't — this is new.
5. Proxy checks the domain+issuer against the cluster-wide pre-approved
list (Goal 3).
- **Matches** → skip straight to step 7, no human involved.
- **Doesn't match** → request is held; surfaced through the proxy's own
review interface for a human to approve. Once approved, continue to
step 6.
6. *(manual path only)* A human approves the request through the proxy.
7. The proxy locates the target Route (by name, or by matching `spec.host`
to the requested domain), stamps it with the requesting user's identity
(Goal 6), and patches its annotations (e.g.
`cert-manager.io/issuer-name: `).
8. `cert-manager/openshift-routes` reacts to the annotation, creates the
`Certificate` object in that namespace.
9. `cert-manager` reconciles the `Certificate` into a `CertificateRequest`
and approves it via its own default built-in behavior — no further
gating.
10. `cert-manager/openshift-routes` injects the certificate into the
Route's `spec.tls` fields.

### Request flow — renewal

Nothing proxy-specific happens. cert-manager notices the existing
`Certificate`'s cert is nearing expiry, creates a new `CertificateRequest`
on its own schedule, approves it via its own default behavior, and
`openshift-routes` re-syncs the Route — exactly cert-manager's existing,
unmodified behavior. This is the point of Goal 4: renewals aren't
special-cased by us at all.

### Request flow — deny future renewals

1. Authorized user requests denial for a namespace/domain via the proxy.
2. Proxy authorizes via the same `TokenReview`/`SubjectAccessReview` check
as creation.
3. Proxy deletes the `Certificate` object and clears the Route's
`cert-manager.io/issuer-name` annotation. cert-manager stops managing
it; no further renewal will ever occur. (Clearing the annotation alone
is not sufficient — cert-manager keeps renewing an existing `Certificate`
object regardless of the originating Route annotation, so the object
itself must go.)

## Design decisions

### approver-policy is dropped; cert-manager reverts to default approval behavior

Originally the design used `disableAutoApproval=true` on cert-manager plus
`approver-policy` to gate approval at the `CertificateRequest` layer,
based on the requesting domain. Tracing approver-policy's actual
evaluation pipeline surfaced two problems that make it unsuitable here:

- Its RBAC `use` check is evaluated against `CertificateRequest.spec.username`,
which — per cert-manager's own documented behavior — is **cert-manager's
own controller ServiceAccount**, not the original caller, whenever the
`CertificateRequest` is auto-generated from a `Certificate` (true for
every path in this architecture). There is no per-caller identity to
hang an RBAC-based decision on at that layer.
- Its only other lever — deny requests that don't match an allowed
policy — produces an immediate, effectively unrecoverable terminal
`Denied` state (cert-manager only retries after an exponentially
increasing backoff, 1–32 hours by default), not a pending queue a human
can act on. It cannot express "auto-approve X, otherwise hold for
review."

Since the proxy now makes the entire approval decision itself, before a
`Certificate` or Route annotation ever exists, a second gate at the
`CertificateRequest` layer is redundant. Reverting cert-manager to its
default approval behavior (no `disableAutoApproval`) lets it unconditionally
approve anything that reaches it — safe specifically because the proxy is
the sole gatekeeper deciding what reaches it in the first place.

### Traceability is via labels/annotations, not `spec.username`

`CertificateRequest.spec.username` is set immutably by the Kubernetes API
server to whoever actually created that object — always cert-manager's own
controller in this architecture, regardless of who created the parent
`Certificate`, impersonated or not. The only way to make it reflect the
real caller is for the proxy to create `CertificateRequest` objects
directly (bypassing `Certificate`), which would break cert-manager's own
renewal tracking and require the proxy to own renewal itself — reopening
the reconcile-loop question this design deliberately avoids. Traceability
is instead recorded as a label/annotation the proxy stamps on the
`Certificate`/`Route` it acts on — durable, doesn't fight cert-manager's
renewal model, and satisfies audit/traceability without relying on a
Kubernetes field that can't actually carry this information here.

## Changes required

**`cert-manager-proxy` (Go app)**
- Replace `requireBearerToken` with TokenReview + SubjectAccessReview
middleware.
- Replace direct `Certificate` creation (`internal/certrequest.
BuildCertificate`, the `dynamic.Interface` create call) with: check
whether the domain+issuer has been approved before, or matches the
pre-approved list; if neither, hold for manual review; on approval (via
either path), locate the target Route, stamp it with the requester's
identity, and patch its `cert-manager.io/issuer-name` (and related)
annotations.
- New: pre-approved domain+issuer allowlist, checked before falling back
to manual review (config source TBD — see "Open questions").
- New: manual-review interface (list pending requests, approve/reject).
- New: "deny future renewals" action (delete `Certificate`, clear Route
annotation), RBAC-checked the same way as creation.
- Status endpoint reports based on the resulting `Certificate`/Route state.

**RBAC (chart)**
- Proxy's ServiceAccount: `get`/`patch` on `Routes` (cluster-wide, all
namespaces), `get`/`list`/`watch`/`delete` on `Certificates`, `create`
on `tokenreviews.authentication.k8s.io` + `subjectaccessreviews.
authorization.k8s.io` (bind the built-in `system:auth-delegator`
ClusterRole).
- Remove the `pre-approved-domains`/`manual-review` `CertificateRequestPolicy`
objects and their RBAC entirely (approver-policy no longer part of the
stack).

**`helmfile.yaml`**
- Remove the `approver-policy` release.
- Remove `disableAutoApproval` from the `cert-manager` release's values.
- Add `cert-manager/openshift-routes` as a new release, `needs:` the
`cert-manager` release.

## Out of scope

- The proxy does not create `Certificate` objects directly (in the normal
flow — see "deny future renewals" for the one place it deletes one).
- The proxy does not run its own watch/reconcile loop for Route syncing or
renewal — both stay entirely `cert-manager`/`openshift-routes`'s
responsibility.
- Non-OpenShift (plain `Ingress`/Gateway API) consumption is not addressed
by this epic.

## Open questions

- **How is the pre-approved domain+issuer list configured and updated?**
Static Helm values (matches this repo's existing pattern — simple, but
every addition needs a redeploy) vs. something an admin can edit live
without a release (e.g. a ConfigMap the proxy watches, or an endpoint on
the proxy itself, gated by its own RBAC check) vs. something more
fine-grained than "domain pattern + issuer" (e.g. also namespace-scoped).
- **Is the proxy actually the *only* path, or just the recommended one?**
cert-manager's own Helm chart aggregates `create`/`patch`/`delete` on
`Certificates` into the standard `admin`/`edit` ClusterRoles by default
(`aggregateClusterRoles: true`), and namespace admins/editors can
typically edit Routes directly already. If Goal 1 needs to be strictly
enforced (not just the nicer path), this aggregation likely needs to be
turned off, or Route/Certificate write access otherwise restricted to
the proxy's own ServiceAccount — otherwise a namespace admin can bypass
the proxy entirely via direct `kubectl`.
- How is "CA/provider" in a request mapped to a specific `ClusterIssuer`
name for the annotation?
- Is the target Route identified explicitly by name in the request, or
resolved by matching `spec.host` against the requested domain?
- What's the source of truth for "has this domain+issuer been approved
before" — a label on the `Certificate` object itself (stateless, derived
from whether it already exists), or a separate record the proxy keeps?
- What does the manual-review interface actually look like (a second HTTP
endpoint on the same API, a separate UI, `kubectl`-based)? Who is
notified that something is pending?

Contributor guide

Open the contributing guide

Research direction

Start by reading the existing Go flow around internal/certrequest.BuildCertificate and the dynamic client call, then inspect the chart RBAC and helmfile.yaml. Map the proposed TokenReview, SubjectAccessReview, review, allowlist, Route annotation, and renewal-denial paths against the open configuration and enforcement questions. Done requires an agreed design and coordinated proxy, RBAC, and Helm changes.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, helm, kubernetes
Domain
cloud, infrastructure, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.