From 375502869b081a332cde03d92794f424249d895a Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Wed, 4 Nov 2026 17:12:34 -0600 Subject: [PATCH 1/8] rpc: refuse revoked client certificates A certificate from `miren auth generate` was good until it expired, with no way to withdraw one short of rotating the CA. The check sits in ServeHTTP rather than an authenticator, because the local and cloud authenticators each turn a certificate into an identity and a check in one would leave a hole in the other. Running ahead of authentication also means a valid bearer token cannot carry a revoked certificate through. Requests read an in-memory set and never the store, so an etcd outage cannot fail auth; the server enforces what it last knew. The listener opens before the entity store exists, so the set starts unloaded and refuses every certificate with a retryable 503 until it loads. Authentication only runs when a request starts, so revoking also has to end what is already running. The set tracks in-flight requests by serial and cancels them, with admission and registration in one critical section so nothing slips between check and revocation. Cancellation is not enough alone: a WebTransport session does not inherit the request context, and detached handlers such as builds keep sending, so revoking closes the session as well. The set only grows. A snapshot can predate a revocation already enforced here, so a serial missing from one proves nothing. --- pkg/rpc/audit.go | 26 ++++ pkg/rpc/revocation.go | 185 ++++++++++++++++++++++ pkg/rpc/revocation_test.go | 306 +++++++++++++++++++++++++++++++++++++ pkg/rpc/server.go | 28 ++++ pkg/rpc/state.go | 19 +++ 5 files changed, 564 insertions(+) create mode 100644 pkg/rpc/revocation.go create mode 100644 pkg/rpc/revocation_test.go diff --git a/pkg/rpc/audit.go b/pkg/rpc/audit.go index 34408584b..ae7cea33c 100644 --- a/pkg/rpc/audit.go +++ b/pkg/rpc/audit.go @@ -278,6 +278,32 @@ func logCertAuth(ctx context.Context, log *slog.Logger, dedup *certAuthDeduper, log.Log(ctx, level, "cert auth", attrs...) } +// logCertRevoked records a request refused for presenting a revoked certificate. +// It shares logCertAuth's deduplication, so a leaked certificate retried in a +// loop costs one record per interval and still reports how often it was tried. +func logCertRevoked(log *slog.Logger, dedup *certAuthDeduper, r *http.Request, cert *x509.Certificate) { + fingerprint := certFingerprint(cert) + shouldLog, suppressed := dedup.record(certAuthKey{ + fingerprint: fingerprint, + host: remoteHost(r.RemoteAddr), + }) + if !shouldLog { + return + } + + attrs := []any{ + "remote", r.RemoteAddr, + "subject", cert.Subject.String(), + "serial", CertSerial(cert.SerialNumber), + "fingerprint", fingerprint, + } + if suppressed > 0 { + attrs = append(attrs, "suppressed", suppressed) + } + + log.Warn("revoked cert refused", attrs...) +} + // logAccess emits a per-request RPC access record for a non-public method. // It is intentionally auth-method-agnostic: every line carries the source IP, // the authenticated subject, and the auth method, so the trail stays useful no diff --git a/pkg/rpc/revocation.go b/pkg/rpc/revocation.go new file mode 100644 index 000000000..6b8b5232a --- /dev/null +++ b/pkg/rpc/revocation.go @@ -0,0 +1,185 @@ +package rpc + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "math/big" + "strings" + "sync" +) + +// AuthErrorCertRevoked marks a client certificate the cluster has revoked. +const AuthErrorCertRevoked = "cert-revoked" + +// CertRevokedError refuses a certificate that verified against the cluster CA +// but has since been revoked. Its reason is disclosed: the caller already holds +// the certificate, so this reveals nothing, and it tells a legitimate user why +// their config stopped working. +type CertRevokedError struct { + Serial string +} + +func (e *CertRevokedError) Error() string { + return fmt.Sprintf("client certificate %s has been revoked", e.Serial) +} + +// AuthErrorCode implements DisclosableAuthError. +func (e *CertRevokedError) AuthErrorCode() string { return AuthErrorCertRevoked } + +var errCertRevocationsNotLoaded = errors.New("certificate revocations not loaded") + +// CertSerial returns the canonical form of a certificate serial number: +// lowercase hex with no separators or leading zeros. Every revocation is stored +// and matched in this form. +func CertSerial(serial *big.Int) string { + return serial.Text(16) +} + +// ParseCertSerial validates a hex serial and returns its canonical form. It +// rejects what no certificate could carry: RFC 5280 serials are positive and fit +// in 20 octets. +func ParseCertSerial(serial string) (string, error) { + n, ok := new(big.Int).SetString(serial, 16) + if !ok || strings.HasPrefix(serial, "+") || strings.HasPrefix(serial, "-") { + return "", fmt.Errorf("invalid certificate serial %q: must be hexadecimal", serial) + } + if n.Sign() <= 0 || n.BitLen() > 160 { + return "", fmt.Errorf("invalid certificate serial %q: must be positive and at most 20 bytes", serial) + } + return CertSerial(n), nil +} + +// CertRevocations is the set of client certificates a server refuses. Requests +// consult only this in-memory set, so a store outage cannot fail authentication: +// the server keeps enforcing what it last knew. It starts unloaded and refuses +// every certificate until the owner has loaded it, because the listener opens +// before the entity store exists. +// +// It also tracks in-flight certificate-authenticated requests. Authentication +// happens when a request starts, so without this a stream opened before a +// revocation would outlive it. +type CertRevocations struct { + mu sync.Mutex + loaded bool + revoked map[string]struct{} + active map[string]map[*context.CancelCauseFunc]struct{} +} + +// NewCertRevocations returns an unloaded set. +func NewCertRevocations() *CertRevocations { + return &CertRevocations{ + revoked: make(map[string]struct{}), + active: make(map[string]map[*context.CancelCauseFunc]struct{}), + } +} + +// Load adds a store snapshot and marks the set loaded. It never removes: a +// snapshot can predate a revocation already enforced here, so a serial's absence +// proves nothing. A revocation therefore lasts until the process restarts. +func (c *CertRevocations) Load(serials []string) { + c.mu.Lock() + defer c.mu.Unlock() + + c.loaded = true + for _, serial := range serials { + c.revokeLocked(serial) + } +} + +// Add revokes a serial and ends its in-flight requests. +func (c *CertRevocations) Add(serial string) { + c.mu.Lock() + defer c.mu.Unlock() + + c.revokeLocked(serial) +} + +// Loaded reports whether the set has been installed. +func (c *CertRevocations) Loaded() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.loaded +} + +// Contains reports whether a canonical serial is revoked. +func (c *CertRevocations) Contains(serial string) bool { + c.mu.Lock() + defer c.mu.Unlock() + _, revoked := c.revoked[serial] + return revoked +} + +func (c *CertRevocations) revokeLocked(serial string) { + c.revoked[serial] = struct{}{} + for cancel := range c.active[serial] { + (*cancel)(&CertRevokedError{Serial: serial}) + } + delete(c.active, serial) +} + +// closeOnRevocation ends a transport session when its certificate is revoked. +// Cancellation alone is not enough: the session does not inherit the request's +// context, and handlers that detach, as builds do, keep sending over it after +// their context ends. A request that ends for any other reason leaves the session +// to its usual teardown. +func closeOnRevocation(ctx context.Context, sess rpcSession) (stop func() bool) { + return context.AfterFunc(ctx, func() { + if _, revoked := errors.AsType[*CertRevokedError](context.Cause(ctx)); revoked { + _ = sess.Close() + } + }) +} + +// admit decides whether a request presenting cert may proceed. The check and the +// registration share one critical section, so a revocation either refuses the +// request here or finds it registered and cancels it. Nothing slips between. +func (c *CertRevocations) admit(ctx context.Context, cert *x509.Certificate) (context.Context, func(), error) { + serial := CertSerial(cert.SerialNumber) + + c.mu.Lock() + defer c.mu.Unlock() + + if !c.loaded { + return nil, nil, errCertRevocationsNotLoaded + } + if _, revoked := c.revoked[serial]; revoked { + return nil, nil, &CertRevokedError{Serial: serial} + } + + ctx, cancel := context.WithCancelCause(ctx) + if c.active[serial] == nil { + c.active[serial] = make(map[*context.CancelCauseFunc]struct{}) + } + c.active[serial][&cancel] = struct{}{} + + release := func() { + cancel(nil) + + c.mu.Lock() + defer c.mu.Unlock() + delete(c.active[serial], &cancel) + if len(c.active[serial]) == 0 { + delete(c.active, serial) + } + } + + return ContextWithCallerCertSerial(ctx, serial), release, nil +} + +type callerCertSerialKey struct{} + +// ContextWithCallerCertSerial records the serial of the caller's verified client +// certificate. +func ContextWithCallerCertSerial(ctx context.Context, serial string) context.Context { + return context.WithValue(ctx, callerCertSerialKey{}, serial) +} + +// CallerCertSerial returns the canonical serial of the verified client +// certificate on this request, or "" when there is none or the server does not +// enforce revocations. +func CallerCertSerial(ctx context.Context) string { + serial, _ := ctx.Value(callerCertSerialKey{}).(string) + return serial +} diff --git a/pkg/rpc/revocation_test.go b/pkg/rpc/revocation_test.go new file mode 100644 index 000000000..940d85d59 --- /dev/null +++ b/pkg/rpc/revocation_test.go @@ -0,0 +1,306 @@ +package rpc_test + +import ( + "context" + "crypto/x509" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "miren.dev/runtime/pkg/caauth" + "miren.dev/runtime/pkg/cond" + "miren.dev/runtime/pkg/rpc" + "miren.dev/runtime/pkg/rpc/example" + "miren.dev/runtime/pkg/rpc/stream" +) + +// heldEmit emits one reading and then holds the stream open until its context +// ends, standing in for exec, attach, or a log follow. +type heldEmit struct { + started chan struct{} + ended chan error +} + +func (h *heldEmit) Emit(ctx context.Context, call *example.EmitTempsEmit) error { + close(h.started) + <-ctx.Done() + h.ended <- context.Cause(ctx) + return ctx.Err() +} + +type revocationHarness struct { + t *testing.T + ca *caauth.Authority + server *rpc.State + revocations *rpc.CertRevocations +} + +func newRevocationHarness(t *testing.T, service func(*rpc.State), opts ...rpc.StateOption) *revocationHarness { + t.Helper() + r := require.New(t) + + ca, err := caauth.New(caauth.Options{CommonName: "test-ca", Organization: "miren", ValidFor: time.Hour}) + r.NoError(err) + serverCert, err := ca.IssueCertificate(caauth.Options{ + CommonName: "test-server", + Organization: "miren", + ValidFor: time.Hour, + DNSNames: []string{"localhost"}, + }) + r.NoError(err) + + revocations := rpc.NewCertRevocations() + ss, err := rpc.NewState(t.Context(), append([]rpc.StateOption{ + rpc.WithCertPEMs(serverCert.CertPEM, serverCert.KeyPEM), + rpc.WithCertificateVerification(ca.GetCACertificate()), + rpc.WithAuthenticator(&rpc.LocalOnlyAuthenticator{}), + rpc.WithCertRevocations(revocations), + }, opts...)...) + r.NoError(err) + service(ss) + + return &revocationHarness{t: t, ca: ca, server: ss, revocations: revocations} +} + +// issue returns a client certificate generated for this test and its serial. +func (h *revocationHarness) issue(cn string) (*caauth.ClientCertificate, string) { + h.t.Helper() + cc, err := h.ca.IssueCertificate(caauth.Options{CommonName: cn, Organization: "miren", ValidFor: time.Hour}) + require.NoError(h.t, err) + + block, _ := pem.Decode(cc.CertPEM) + require.NotNil(h.t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(h.t, err) + return cc, rpc.CertSerial(cert.SerialNumber) +} + +func (h *revocationHarness) connect(cc *caauth.ClientCertificate) (*example.EmitTempsClient, error) { + h.t.Helper() + opts := []rpc.StateOption{rpc.WithSkipVerify} + if cc != nil { + opts = append(opts, rpc.WithCertPEMs(cc.CertPEM, cc.KeyPEM)) + } + cs, err := rpc.NewState(h.t.Context(), opts...) + require.NoError(h.t, err) + + c, err := cs.Connect(h.server.ListenAddr(), "meter") + if err != nil { + return nil, err + } + return &example.EmitTempsClient{Client: c}, nil +} + +func emit(ctx context.Context, c *example.EmitTempsClient) error { + _, err := c.Emit(ctx, stream.StreamRecv(func(float32) error { return nil })) + return err +} + +func TestRevokedCertificateIsRefused(t *testing.T) { + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(&exampleEmit{})) + }) + h.revocations.Load(nil) + + kept, _ := h.issue("kept") + revoked, serial := h.issue("revoked") + h.revocations.Add(serial) + + t.Run("a new connection is told why", func(t *testing.T) { + _, err := h.connect(revoked) + var resolve *rpc.ResolveError + require.ErrorAs(t, err, &resolve) + require.Equal(t, rpc.AuthErrorCertRevoked, resolve.Code) + require.Contains(t, resolve.Detail, serial) + }) + + t.Run("other certificates are unaffected", func(t *testing.T) { + c, err := h.connect(kept) + require.NoError(t, err) + require.NoError(t, emit(t.Context(), c)) + }) +} + +// An established QUIC connection must not grandfather a certificate in: the +// next request on it is refused. +func TestRevocationReachesEstablishedConnection(t *testing.T) { + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(&exampleEmit{})) + }) + h.revocations.Load(nil) + + cc, serial := h.issue("connected") + c, err := h.connect(cc) + require.NoError(t, err) + require.NoError(t, emit(t.Context(), c)) + + h.revocations.Add(serial) + + err = emit(t.Context(), c) + var denial cond.ErrRemote + require.ErrorAs(t, err, &denial) + require.Equal(t, "unauthorized", denial.Code) +} + +// A callstream is authenticated only when it opens, so revoking must end the +// ones already running rather than wait for them to finish. +func TestRevocationEndsOpenStream(t *testing.T) { + held := &heldEmit{started: make(chan struct{}), ended: make(chan error, 1)} + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(held)) + }) + h.revocations.Load(nil) + + cc, serial := h.issue("streaming") + c, err := h.connect(cc) + require.NoError(t, err) + + clientDone := make(chan error, 1) + go func() { clientDone <- emit(t.Context(), c) }() + + select { + case <-held.started: + case <-time.After(10 * time.Second): + t.Fatal("stream never started") + } + + h.revocations.Add(serial) + + select { + case cause := <-held.ended: + var revoked *rpc.CertRevokedError + require.ErrorAs(t, cause, &revoked) + case <-time.After(10 * time.Second): + t.Fatal("handler outlived the revocation") + } + + select { + case err := <-clientDone: + require.Error(t, err) + case <-time.After(10 * time.Second): + t.Fatal("client stream outlived the revocation") + } +} + +// detachedEmit keeps sending from a detached context, ignoring cancellation the +// way a running build does. +type detachedEmit struct { + started chan struct{} + stopped chan struct{} +} + +func (d *detachedEmit) Emit(ctx context.Context, call *example.EmitTempsEmit) error { + detached, cancel := rpc.Detach(ctx) + defer cancel() + defer close(d.stopped) + + emitter := call.Args().Emitter() + close(d.started) + for { + if _, err := emitter.Send(detached, 1); err != nil { + return err + } + time.Sleep(5 * time.Millisecond) + } +} + +// Cancellation only reaches handlers that honor it, so revoking has to cut the +// transport as well. +func TestRevocationStopsDetachedStream(t *testing.T) { + detached := &detachedEmit{started: make(chan struct{}), stopped: make(chan struct{})} + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(detached)) + }) + h.revocations.Load(nil) + + cc, serial := h.issue("building") + c, err := h.connect(cc) + require.NoError(t, err) + + clientDone := make(chan error, 1) + go func() { clientDone <- emit(t.Context(), c) }() + + select { + case <-detached.started: + case <-time.After(10 * time.Second): + t.Fatal("stream never started") + } + + h.revocations.Add(serial) + + select { + case <-clientDone: + case <-time.After(10 * time.Second): + t.Fatal("client kept receiving after the revocation") + } + select { + case <-detached.stopped: + case <-time.After(10 * time.Second): + t.Fatal("handler kept sending after the revocation") + } +} + +func TestCertsWaitForRevocationsToLoad(t *testing.T) { + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(&exampleEmit{})) + }) + cc, _ := h.issue("early") + + _, err := h.connect(cc) + var resolve *rpc.ResolveError + require.ErrorAs(t, err, &resolve) + require.Equal(t, 503, resolve.StatusCode, "a cert must not get in before the set loads") + + // Certless callers authenticate some other way, so the gate leaves them be. + _, err = h.connect(nil) + require.NoError(t, err) + + h.revocations.Load(nil) + + c, err := h.connect(cc) + require.NoError(t, err) + require.NoError(t, emit(t.Context(), c)) +} + +type alwaysAuthenticated struct{} + +func (alwaysAuthenticated) Authenticate(context.Context, *rpc.Credentials) (*rpc.Identity, error) { + return &rpc.Identity{Subject: "bearer-user", Method: rpc.AuthMethodJWT}, nil +} + +// The revocation check sits ahead of authentication, so a credential that would +// otherwise succeed cannot carry a revoked certificate through. +func TestRevokedCertificateBeatsOtherCredentials(t *testing.T) { + h := newRevocationHarness(t, func(s *rpc.State) { + s.Server().ExposeValue("meter", example.AdaptEmitTemps(&exampleEmit{})) + }, rpc.WithAuthenticator(alwaysAuthenticated{})) + h.revocations.Load(nil) + + cc, serial := h.issue("both") + h.revocations.Add(serial) + + _, err := h.connect(cc) + var resolve *rpc.ResolveError + require.ErrorAs(t, err, &resolve) + require.Equal(t, rpc.AuthErrorCertRevoked, resolve.Code) +} + +func TestCertSerialIsCanonical(t *testing.T) { + serial, ok := new(big.Int).SetString("0ABC", 16) + require.True(t, ok) + require.Equal(t, "abc", rpc.CertSerial(serial)) +} + +func TestParseCertSerial(t *testing.T) { + serial, err := rpc.ParseCertSerial("17CAFA9B5C6FE138F6E0113E5686A658") + require.NoError(t, err) + require.Equal(t, "17cafa9b5c6fe138f6e0113e5686a658", serial) + + for _, bad := range []string{"", "0", "-1", "+1", "zz", "1:2", strings.Repeat("f", 41)} { + _, err := rpc.ParseCertSerial(bad) + require.Error(t, err, "serial %q", bad) + } +} diff --git a/pkg/rpc/server.go b/pkg/rpc/server.go index 333ea7e70..71f31facc 100644 --- a/pkg/rpc/server.go +++ b/pkg/rpc/server.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/x509" "encoding/json" "errors" "fmt" @@ -528,6 +529,20 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // This runs ahead of the authenticator so that one check covers every way a + // certificate becomes an identity. A revoked certificate is a compromised + // credential, so it refuses the request even alongside a valid bearer token. + if cert := CredentialsFromRequest(r).VerifiedPeerCertificate(); cert != nil && s.state.certRevocations != nil { + admitted, release, err := s.state.certRevocations.admit(ctx, cert) + if err != nil { + s.refuseCert(w, r, cert, err) + return + } + defer release() + ctx = admitted + r = r.WithContext(ctx) + } + // If no authenticator configured, allow all requests if s.state.authenticator == nil { s.mux.ServeHTTP(w, r) @@ -566,6 +581,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } +// refuseCert answers a request whose certificate failed the revocation check. A +// set that has not loaded yet is a boot-time condition the caller should retry; +// anything else is a denial for the audit trail. +func (s *Server) refuseCert(w http.ResponseWriter, r *http.Request, cert *x509.Certificate, err error) { + if errors.Is(err, errCertRevocationsNotLoaded) { + http.Error(w, "server starting", http.StatusServiceUnavailable) + return + } + logCertRevoked(s.state.audit(), s.state.certDenied, r, cert) + writeAuthFailure(w, err) +} + // rpcPathPrefix is the path namespace reserved for RPC dispatch. const rpcPathPrefix = "/_rpc/" @@ -1215,6 +1242,7 @@ func (s *Server) startCallStream(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to upgrade connection", http.StatusInternalServerError) return } + defer closeOnRevocation(ctx, sess)() ctrlstream, err := sess.AcceptStream(ctx) if err != nil { diff --git a/pkg/rpc/state.go b/pkg/rpc/state.go index a8237e890..21e918d60 100644 --- a/pkg/rpc/state.go +++ b/pkg/rpc/state.go @@ -78,6 +78,12 @@ type StateCommon struct { // certAuthDeduper. certAuth *certAuthDeduper + // certRevocations is the set of client certificates this server refuses. + // Nil means it does not enforce revocations. certDenied bounds the audit + // record a revoked certificate can produce by retrying. + certRevocations *CertRevocations + certDenied *certAuthDeduper + opts *stateOptions serverTlsCfg *tls.Config @@ -225,6 +231,7 @@ type stateOptions struct { authenticator Authenticator authorizer Authorizer + certRevocations *CertRevocations bearerToken string // JWT or other bearer token for authentication bearerTokenFunc func() (string, error) tlsServerName string @@ -385,6 +392,15 @@ func WithHTTPHandler(pattern string, handler http.Handler) StateOption { } } +// WithCertRevocations enforces a revocation set on every request that presents a +// verified client certificate. Until the set is loaded, such requests are refused +// with a retryable 503. +func WithCertRevocations(revocations *CertRevocations) StateOption { + return func(o *stateOptions) { + o.certRevocations = revocations + } +} + func WithBearerToken(token string) StateOption { return func(o *stateOptions) { o.bearerToken = token @@ -514,12 +530,15 @@ func NewState(ctx context.Context, opts ...StateOption) (*State, error) { log: so.log, auditLog: newAuditLogger(so.log), certAuth: newCertAuthDeduper(), + certDenied: newCertAuthDeduper(), opts: &so, clientTlsCfg: tlsCfg, privkey: priv, pubkey: pub, authenticator: authenticator, authorizer: so.authorizer, + + certRevocations: so.certRevocations, }, cancel: cancelState, From fe3c82c1c98eea00094e8998685c10a9e41ac122 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Wed, 4 Nov 2026 17:14:31 -0600 Subject: [PATCH 2/8] Store certificate revocations and serve them to operators Revocations are entities named for their serial, so revoking twice lands on one record. The server keeps no ledger of what it issued, so Revoke accepts any well-formed serial and cannot tell a typo from a real certificate. Boot fails closed. The load reads the store directly, since the loopback client would go through the listener waiting on it, and any record that cannot be read or enforced stops the server: it might be the only thing keeping a revoked certificate out. After boot it fails static. Revoke writes through so the certificate is refused by the time it returns, and a watch picks up anything else. The watch only adds. A resync can be older than a write-through and listings omit undecodable records, and a queued delete can be older than a fresh Revoke of the same serial. So deleting a revocation by hand takes effect on restart, and there is deliberately no un-revoke API. The service checks identity itself because the authorizer waves every certificate through, and runner certificates must not qualify. Revoke also refuses the certificate it is running with. --- api/certrevocation/certrevocation.go | 4 + .../certrevocation_v1alpha/rpc.gen.go | 412 ++++++++++++++++++ api/certrevocation/rpc.yml | 54 +++ api/core/core_v1alpha/schema.gen.go | 115 ++++- api/core/schema.yml | 19 + components/coordinate/foundation.go | 28 ++ pkg/certrevocation/certrevocation.go | 135 ++++++ pkg/certrevocation/certrevocation_test.go | 161 +++++++ pkg/rpc/service.go | 3 + servers/certrevocation/server.go | 134 ++++++ servers/certrevocation/server_test.go | 112 +++++ 11 files changed, 1164 insertions(+), 13 deletions(-) create mode 100644 api/certrevocation/certrevocation.go create mode 100644 api/certrevocation/certrevocation_v1alpha/rpc.gen.go create mode 100644 api/certrevocation/rpc.yml create mode 100644 pkg/certrevocation/certrevocation.go create mode 100644 pkg/certrevocation/certrevocation_test.go create mode 100644 servers/certrevocation/server.go create mode 100644 servers/certrevocation/server_test.go diff --git a/api/certrevocation/certrevocation.go b/api/certrevocation/certrevocation.go new file mode 100644 index 000000000..8b356cadb --- /dev/null +++ b/api/certrevocation/certrevocation.go @@ -0,0 +1,4 @@ +package certrevocation + +//go:generate mkdir -p certrevocation_v1alpha +//go:generate go run ../../pkg/rpc/cmd/rpcgen -pkg certrevocation_v1alpha -input rpc.yml -output certrevocation_v1alpha/rpc.gen.go diff --git a/api/certrevocation/certrevocation_v1alpha/rpc.gen.go b/api/certrevocation/certrevocation_v1alpha/rpc.gen.go new file mode 100644 index 000000000..b4b7a9b71 --- /dev/null +++ b/api/certrevocation/certrevocation_v1alpha/rpc.gen.go @@ -0,0 +1,412 @@ +package certrevocation_v1alpha + +import ( + "context" + "encoding/json" + "slices" + + "github.com/fxamacker/cbor/v2" + rpc "miren.dev/runtime/pkg/rpc" +) + +type revocationData struct { + Serial *string `cbor:"0,keyasint,omitempty" json:"serial,omitempty"` + RevokedAt *string `cbor:"1,keyasint,omitempty" json:"revoked_at,omitempty"` + RevokedBy *string `cbor:"2,keyasint,omitempty" json:"revoked_by,omitempty"` + Reason *string `cbor:"3,keyasint,omitempty" json:"reason,omitempty"` +} + +type Revocation struct { + data revocationData +} + +func (v *Revocation) HasSerial() bool { + return v.data.Serial != nil +} + +func (v *Revocation) Serial() string { + if v.data.Serial == nil { + return "" + } + return *v.data.Serial +} + +func (v *Revocation) SetSerial(serial string) { + v.data.Serial = &serial +} + +func (v *Revocation) HasRevokedAt() bool { + return v.data.RevokedAt != nil +} + +func (v *Revocation) RevokedAt() string { + if v.data.RevokedAt == nil { + return "" + } + return *v.data.RevokedAt +} + +func (v *Revocation) SetRevokedAt(revoked_at string) { + v.data.RevokedAt = &revoked_at +} + +func (v *Revocation) HasRevokedBy() bool { + return v.data.RevokedBy != nil +} + +func (v *Revocation) RevokedBy() string { + if v.data.RevokedBy == nil { + return "" + } + return *v.data.RevokedBy +} + +func (v *Revocation) SetRevokedBy(revoked_by string) { + v.data.RevokedBy = &revoked_by +} + +func (v *Revocation) HasReason() bool { + return v.data.Reason != nil +} + +func (v *Revocation) Reason() string { + if v.data.Reason == nil { + return "" + } + return *v.data.Reason +} + +func (v *Revocation) SetReason(reason string) { + v.data.Reason = &reason +} + +func (v *Revocation) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *Revocation) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *Revocation) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *Revocation) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type certRevocationsRevokeArgsData struct { + Serial *string `cbor:"0,keyasint,omitempty" json:"serial,omitempty"` + Reason *string `cbor:"1,keyasint,omitempty" json:"reason,omitempty"` +} + +type CertRevocationsRevokeArgs struct { + call rpc.Call + data certRevocationsRevokeArgsData +} + +func (v *CertRevocationsRevokeArgs) HasSerial() bool { + return v.data.Serial != nil +} + +func (v *CertRevocationsRevokeArgs) Serial() string { + if v.data.Serial == nil { + return "" + } + return *v.data.Serial +} + +func (v *CertRevocationsRevokeArgs) HasReason() bool { + return v.data.Reason != nil +} + +func (v *CertRevocationsRevokeArgs) Reason() string { + if v.data.Reason == nil { + return "" + } + return *v.data.Reason +} + +func (v *CertRevocationsRevokeArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *CertRevocationsRevokeArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *CertRevocationsRevokeArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *CertRevocationsRevokeArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type certRevocationsRevokeResultsData struct { + Revocation *Revocation `cbor:"0,keyasint,omitempty" json:"revocation,omitempty"` + AlreadyRevoked *bool `cbor:"1,keyasint,omitempty" json:"already_revoked,omitempty"` +} + +type CertRevocationsRevokeResults struct { + call rpc.Call + data certRevocationsRevokeResultsData +} + +func (v *CertRevocationsRevokeResults) SetRevocation(revocation *Revocation) { + v.data.Revocation = revocation +} + +func (v *CertRevocationsRevokeResults) SetAlreadyRevoked(already_revoked bool) { + v.data.AlreadyRevoked = &already_revoked +} + +func (v *CertRevocationsRevokeResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *CertRevocationsRevokeResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *CertRevocationsRevokeResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *CertRevocationsRevokeResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type certRevocationsListArgsData struct{} + +type CertRevocationsListArgs struct { + call rpc.Call + data certRevocationsListArgsData +} + +func (v *CertRevocationsListArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *CertRevocationsListArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *CertRevocationsListArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *CertRevocationsListArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type certRevocationsListResultsData struct { + Revocations *[]*Revocation `cbor:"0,keyasint,omitempty" json:"revocations,omitempty"` +} + +type CertRevocationsListResults struct { + call rpc.Call + data certRevocationsListResultsData +} + +func (v *CertRevocationsListResults) SetRevocations(revocations []*Revocation) { + x := slices.Clone(revocations) + v.data.Revocations = &x +} + +func (v *CertRevocationsListResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *CertRevocationsListResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *CertRevocationsListResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *CertRevocationsListResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type CertRevocationsRevoke struct { + rpc.Call + args CertRevocationsRevokeArgs + results CertRevocationsRevokeResults +} + +func (t *CertRevocationsRevoke) Args() *CertRevocationsRevokeArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *CertRevocationsRevoke) Results() *CertRevocationsRevokeResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type CertRevocationsList struct { + rpc.Call + args CertRevocationsListArgs + results CertRevocationsListResults +} + +func (t *CertRevocationsList) Args() *CertRevocationsListArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *CertRevocationsList) Results() *CertRevocationsListResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type CertRevocations interface { + Revoke(ctx context.Context, state *CertRevocationsRevoke) error + List(ctx context.Context, state *CertRevocationsList) error +} + +type reexportCertRevocations struct { + client rpc.Client +} + +func (reexportCertRevocations) Revoke(ctx context.Context, state *CertRevocationsRevoke) error { + panic("not implemented") +} + +func (reexportCertRevocations) List(ctx context.Context, state *CertRevocationsList) error { + panic("not implemented") +} + +func (t reexportCertRevocations) CapabilityClient() rpc.Client { + return t.client +} + +func AdaptCertRevocations(t CertRevocations) *rpc.Interface { + methods := []rpc.Method{ + { + Name: "Revoke", + InterfaceName: "CertRevocations", + Index: 0, + Public: false, + Params: []string{"serial", "reason"}, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.Revoke(ctx, &CertRevocationsRevoke{Call: call}) + }, + }, + { + Name: "List", + InterfaceName: "CertRevocations", + Index: 1, + Public: false, + Params: []string{}, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.List(ctx, &CertRevocationsList{Call: call}) + }, + }, + } + + return rpc.NewInterface(methods, t) +} + +type CertRevocationsClient struct { + rpc.Client +} + +func NewCertRevocationsClient(client rpc.Client) *CertRevocationsClient { + return &CertRevocationsClient{Client: client} +} + +func (c CertRevocationsClient) Export() CertRevocations { + return reexportCertRevocations{client: c.Client} +} + +type CertRevocationsClientRevokeResults struct { + client rpc.Client + data certRevocationsRevokeResultsData +} + +func (v *CertRevocationsClientRevokeResults) HasRevocation() bool { + return v.data.Revocation != nil +} + +func (v *CertRevocationsClientRevokeResults) Revocation() *Revocation { + return v.data.Revocation +} + +func (v *CertRevocationsClientRevokeResults) HasAlreadyRevoked() bool { + return v.data.AlreadyRevoked != nil +} + +func (v *CertRevocationsClientRevokeResults) AlreadyRevoked() bool { + if v.data.AlreadyRevoked == nil { + return false + } + return *v.data.AlreadyRevoked +} + +func (v CertRevocationsClient) Revoke(ctx context.Context, serial string, reason string) (*CertRevocationsClientRevokeResults, error) { + args := CertRevocationsRevokeArgs{} + args.data.Serial = &serial + args.data.Reason = &reason + + var ret certRevocationsRevokeResultsData + + err := v.Call(ctx, "Revoke", &args, &ret) + if err != nil { + return nil, err + } + + return &CertRevocationsClientRevokeResults{client: v.Client, data: ret}, nil +} + +type CertRevocationsClientListResults struct { + client rpc.Client + data certRevocationsListResultsData +} + +func (v *CertRevocationsClientListResults) HasRevocations() bool { + return v.data.Revocations != nil +} + +func (v *CertRevocationsClientListResults) Revocations() []*Revocation { + if v.data.Revocations == nil { + return nil + } + return *v.data.Revocations +} + +func (v CertRevocationsClient) List(ctx context.Context) (*CertRevocationsClientListResults, error) { + args := CertRevocationsListArgs{} + + var ret certRevocationsListResultsData + + err := v.Call(ctx, "List", &args, &ret) + if err != nil { + return nil, err + } + + return &CertRevocationsClientListResults{client: v.Client, data: ret}, nil +} diff --git a/api/certrevocation/rpc.yml b/api/certrevocation/rpc.yml new file mode 100644 index 000000000..2d048a1bc --- /dev/null +++ b/api/certrevocation/rpc.yml @@ -0,0 +1,54 @@ +types: + - type: Revocation + doc: A client certificate the cluster refuses + fields: + - name: serial + type: string + index: 0 + doc: Certificate serial in canonical lowercase hex + - name: revoked_at + type: string + index: 1 + doc: When the certificate was revoked, as RFC3339 + - name: revoked_by + type: string + index: 2 + doc: Subject of the operator who revoked it + - name: reason + type: string + index: 3 + doc: Why it was revoked + +interfaces: + - name: CertRevocations + doc: | + Client certificate revocation for cluster operators. The server keeps no + record of the certificates it issues, so Revoke accepts any well-formed + serial and cannot tell a typo from a real certificate. + methods: + - name: Revoke + index: 0 + doc: Revoke a client certificate by serial. Revoking one twice is harmless. + parameters: + - name: serial + type: string + doc: Certificate serial in hexadecimal + - name: reason + type: string + doc: Why the certificate is being revoked + results: + - name: revocation + type: Revocation + doc: The revocation now in force + - name: already_revoked + type: bool + doc: Whether the serial was revoked before this call + + - name: List + index: 1 + doc: List revoked client certificates, oldest first + results: + - name: revocations + type: list + element: Revocation + doc: Every revocation in force diff --git a/api/core/core_v1alpha/schema.gen.go b/api/core/core_v1alpha/schema.gen.go index 5eef20501..449775d41 100644 --- a/api/core/core_v1alpha/schema.gen.go +++ b/api/core/core_v1alpha/schema.gen.go @@ -2666,6 +2666,93 @@ func (o *Artifact) InitSchema(sb *schema.SchemaBuilder) { sb.Ref("status", "dev.miren.core/artifact.status", schema.Doc("Artifact lifecycle status"), schema.Indexed, schema.Choices(ArtifactStatusActiveId, ArtifactStatusArchivedId)) } +const ( + CertificateRevocationReasonId = entity.Id("dev.miren.core/certificate_revocation.reason") + CertificateRevocationRevokedAtId = entity.Id("dev.miren.core/certificate_revocation.revoked_at") + CertificateRevocationRevokedById = entity.Id("dev.miren.core/certificate_revocation.revoked_by") + CertificateRevocationSerialId = entity.Id("dev.miren.core/certificate_revocation.serial") +) + +type CertificateRevocation struct { + ID entity.Id `json:"id"` + Reason string `cbor:"reason,omitempty" json:"reason,omitempty"` + RevokedAt time.Time `cbor:"revoked_at,omitempty" json:"revoked_at"` + RevokedBy string `cbor:"revoked_by,omitempty" json:"revoked_by,omitempty"` + Serial string `cbor:"serial,omitempty" json:"serial,omitempty"` +} + +func (o *CertificateRevocation) Decode(e entity.AttrGetter) { + o.ID = entity.MustGet(e, entity.DBId).Value.Id() + if a, ok := e.Get(CertificateRevocationReasonId); ok && a.Value.Kind() == entity.KindString { + o.Reason = a.Value.String() + } + if a, ok := e.Get(CertificateRevocationRevokedAtId); ok && a.Value.Kind() == entity.KindTime { + o.RevokedAt = a.Value.Time() + } + if a, ok := e.Get(CertificateRevocationRevokedById); ok && a.Value.Kind() == entity.KindString { + o.RevokedBy = a.Value.String() + } + if a, ok := e.Get(CertificateRevocationSerialId); ok && a.Value.Kind() == entity.KindString { + o.Serial = a.Value.String() + } +} + +func (o *CertificateRevocation) Is(e entity.AttrGetter) bool { + return entity.Is(e, KindCertificateRevocation) +} + +func (o *CertificateRevocation) ShortKind() string { + return "certificate_revocation" +} + +func (o *CertificateRevocation) Kind() entity.Id { + return KindCertificateRevocation +} + +func (o *CertificateRevocation) EntityId() entity.Id { + return o.ID +} + +func (o *CertificateRevocation) Encode() (attrs []entity.Attr) { + if !entity.Empty(o.Reason) { + attrs = append(attrs, entity.String(CertificateRevocationReasonId, o.Reason)) + } + if !entity.Empty(o.RevokedAt) { + attrs = append(attrs, entity.Time(CertificateRevocationRevokedAtId, o.RevokedAt)) + } + if !entity.Empty(o.RevokedBy) { + attrs = append(attrs, entity.String(CertificateRevocationRevokedById, o.RevokedBy)) + } + if !entity.Empty(o.Serial) { + attrs = append(attrs, entity.String(CertificateRevocationSerialId, o.Serial)) + } + attrs = append(attrs, entity.Ref(entity.EntityKind, KindCertificateRevocation)) + return +} + +func (o *CertificateRevocation) Empty() bool { + if !entity.Empty(o.Reason) { + return false + } + if !entity.Empty(o.RevokedAt) { + return false + } + if !entity.Empty(o.RevokedBy) { + return false + } + if !entity.Empty(o.Serial) { + return false + } + return true +} + +func (o *CertificateRevocation) InitSchema(sb *schema.SchemaBuilder) { + sb.String("reason", "dev.miren.core/certificate_revocation.reason", schema.Doc("Why the certificate was revoked")) + sb.Time("revoked_at", "dev.miren.core/certificate_revocation.revoked_at", schema.Doc("When the certificate was revoked")) + sb.String("revoked_by", "dev.miren.core/certificate_revocation.revoked_by", schema.Doc("The subject of the operator who revoked the certificate")) + sb.String("serial", "dev.miren.core/certificate_revocation.serial", schema.Doc("The certificate serial number in canonical form (lowercase hex, no separators or leading zeros)")) +} + const ( ConfigVersionAppId = entity.Id("dev.miren.core/config_version.app") ConfigVersionSpecId = entity.Id("dev.miren.core/config_version.spec") @@ -3825,18 +3912,19 @@ func (o *SecretVersion) InitSchema(sb *schema.SchemaBuilder) { } var ( - KindApp = entity.Id("dev.miren.core/kind.app") - KindAppVersion = entity.Id("dev.miren.core/kind.app_version") - KindArtifact = entity.Id("dev.miren.core/kind.artifact") - KindConfigVersion = entity.Id("dev.miren.core/kind.config_version") - KindDeployment = entity.Id("dev.miren.core/kind.deployment") - KindKeyRotation = entity.Id("dev.miren.core/kind.key_rotation") - KindMetadata = entity.Id("dev.miren.core/kind.metadata") - KindOidcBinding = entity.Id("dev.miren.core/kind.oidc_binding") - KindProject = entity.Id("dev.miren.core/kind.project") - KindSecret = entity.Id("dev.miren.core/kind.secret") - KindSecretVersion = entity.Id("dev.miren.core/kind.secret_version") - Schema = entity.Id("dev.miren.core/schema.v1alpha") + KindApp = entity.Id("dev.miren.core/kind.app") + KindAppVersion = entity.Id("dev.miren.core/kind.app_version") + KindArtifact = entity.Id("dev.miren.core/kind.artifact") + KindCertificateRevocation = entity.Id("dev.miren.core/kind.certificate_revocation") + KindConfigVersion = entity.Id("dev.miren.core/kind.config_version") + KindDeployment = entity.Id("dev.miren.core/kind.deployment") + KindKeyRotation = entity.Id("dev.miren.core/kind.key_rotation") + KindMetadata = entity.Id("dev.miren.core/kind.metadata") + KindOidcBinding = entity.Id("dev.miren.core/kind.oidc_binding") + KindProject = entity.Id("dev.miren.core/kind.project") + KindSecret = entity.Id("dev.miren.core/kind.secret") + KindSecretVersion = entity.Id("dev.miren.core/kind.secret_version") + Schema = entity.Id("dev.miren.core/schema.v1alpha") ) var CloudExportContract = export.MustParse("{\"version\":1,\"target\":\"cloud\",\"marker\":\"dev.miren.meta/cloud.export\",\"kinds\":[{\"id\":\"dev.miren.compute/kind.node\",\"lifecycle\":\"mirror\",\"attributes\":[{\"id\":\"db/short-id\",\"type\":\"string\"},{\"id\":\"dev.miren.compute/node.api_address\",\"type\":\"string\"},{\"id\":\"dev.miren.compute/node.constraints\",\"type\":\"label\",\"many\":true},{\"id\":\"dev.miren.compute/node.name\",\"type\":\"string\"},{\"id\":\"dev.miren.compute/node.registered_at\",\"type\":\"time\"},{\"id\":\"dev.miren.compute/node.runner_id\",\"type\":\"string\"},{\"id\":\"dev.miren.compute/node.scheduling\",\"type\":\"enum\",\"enum_values\":[\"dev.miren.compute/scheduling.cordoned\",\"dev.miren.compute/scheduling.schedulable\"]},{\"id\":\"dev.miren.compute/node.status\",\"type\":\"enum\",\"enum_values\":[\"dev.miren.compute/status.disabled\",\"dev.miren.compute/status.ready\",\"dev.miren.compute/status.unhealthy\",\"dev.miren.compute/status.unknown\"]},{\"id\":\"dev.miren.compute/node.version\",\"type\":\"string\"}]},{\"id\":\"dev.miren.core/kind.app\",\"lifecycle\":\"mirror\",\"attributes\":[{\"id\":\"dev.miren.core/app.active_deployment\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/app.active_version\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/metadata.name\",\"type\":\"string\"}]},{\"id\":\"dev.miren.core/kind.app_version\",\"lifecycle\":\"archive\",\"attributes\":[{\"id\":\"db/short-id\",\"type\":\"string\"},{\"id\":\"dev.miren.core/app_version.app\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/app_version.manifest_digest\",\"type\":\"string\"},{\"id\":\"dev.miren.core/app_version.source\",\"type\":\"component\"},{\"id\":\"dev.miren.core/app_version.version\",\"type\":\"string\"},{\"id\":\"dev.miren.core/source.git_branch\",\"type\":\"string\",\"parent\":\"dev.miren.core/app_version.source\"},{\"id\":\"dev.miren.core/source.git_sha\",\"type\":\"string\",\"parent\":\"dev.miren.core/app_version.source\"},{\"id\":\"dev.miren.core/source.kind\",\"type\":\"string\",\"parent\":\"dev.miren.core/app_version.source\"},{\"id\":\"dev.miren.core/source.repository\",\"type\":\"string\",\"parent\":\"dev.miren.core/app_version.source\"},{\"id\":\"dev.miren.core/source.value\",\"type\":\"string\",\"parent\":\"dev.miren.core/app_version.source\"}]},{\"id\":\"dev.miren.core/kind.deployment\",\"lifecycle\":\"archive\",\"attributes\":[{\"id\":\"db/short-id\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployed_by.auth_method\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.deployed_by\"},{\"id\":\"dev.miren.core/deployed_by.organization_id\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.deployed_by\"},{\"id\":\"dev.miren.core/deployed_by.subject\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.deployed_by\"},{\"id\":\"dev.miren.core/deployment.app\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/deployment.app_name\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.completed_at\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.deployed_by\",\"type\":\"component\"},{\"id\":\"dev.miren.core/deployment.git_info\",\"type\":\"component\"},{\"id\":\"dev.miren.core/deployment.message\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.operation\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.outcome\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.parent_deployment\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/deployment.phase\",\"type\":\"string\"},{\"id\":\"dev.miren.core/deployment.started_at\",\"type\":\"time\"},{\"id\":\"dev.miren.core/deployment.version\",\"type\":\"ref\"},{\"id\":\"dev.miren.core/git_info.author\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.branch\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.commit_author_email\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.commit_timestamp\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.is_dirty\",\"type\":\"bool\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.message\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.repository\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.sha\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"},{\"id\":\"dev.miren.core/git_info.working_tree_hash\",\"type\":\"string\",\"parent\":\"dev.miren.core/deployment.git_info\"}]},{\"id\":\"dev.miren.ingress/kind.http_route\",\"lifecycle\":\"mirror\",\"attributes\":[{\"id\":\"dev.miren.ingress/http_route.app\",\"type\":\"ref\"},{\"id\":\"dev.miren.ingress/http_route.default\",\"type\":\"bool\"},{\"id\":\"dev.miren.ingress/http_route.host\",\"type\":\"string\"}]}]}") @@ -3846,6 +3934,7 @@ func init() { (&App{}).InitSchema(sb) (&AppVersion{}).InitSchema(sb) (&Artifact{}).InitSchema(sb) + (&CertificateRevocation{}).InitSchema(sb) (&ConfigVersion{}).InitSchema(sb) (&Deployment{}).InitSchema(sb) (&KeyRotation{}).InitSchema(sb) @@ -3855,5 +3944,5 @@ func init() { (&Secret{}).InitSchema(sb) (&SecretVersion{}).InitSchema(sb) }) - schema.RegisterEncodedSchema("dev.miren.core", "v1alpha", []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xdc\\۲\xec6\xd1~\x8d??\t\x10\xce\x04\xe2$\x84p&\xc5\xe1\x82*nx\x04\x97\xc6\xd6x\xb4ƶ\xbce\xcd\xec5\xb9ۜ)\n\xaax\x05\xf6\u07b9\xe1\xf9\xe0\x9a\xb2N\x96ڲ\xd5\xf2,\xaa(nV\xa9e\xf5\xa7Sw\xab\xd5\xea5\xaf\xea\x9et\xb4\xaf\xe9\xb5蘠}QqA\xe9\x99\xf5\xf5\xf8\x8f\xe7a\xed{SmA\x86\xe1S\xc5#\xc0W2\f\x9a\xef_ǚw\x84\xf5\x00\xf4xd\xb4\xad\xc7?\xbc<\xb0\xfa\xf1KK\xe6\x82T\x92]iYӡ巎\xf6Ru\xf3lY-o\x03=\xb0Z\x01\xbd\xbd\x0et\xa5bd\xbc\xd7\x13\x04u\x06\xe2\xd5\x04\xf1\x85\b\xc4\xdc[\xd9\xf2\xea\xac08\xac\x9c@XŻ\x81\xf7\xb4\x97sI\xaf\x0f\xc4-\"\xb8\x98\x05\xfb\x8d\x9a\xe7;`\x90\x00\xa8 ճ\v\x13\xb4.\x89^\xb6\xb3_1\r\xb4\x96\xac\xa3\n\xea\x9b\t(\x8ff\xb5\x02\xebª\t\xee8J\xc1\xfaF\x01~=\x01H\x1f\a&\xe8h\x87\xf6\xe0\xd1nd\x8dٙ\xe6\xfa\x01i\x87\x13i\a\xc1:\"n\xe5\xb4B\xfd\x8c8\x01\xaen<\xeb\x99d\xa4-+\xde\x1fY\xa37\x1e\xd4\xf9\xb2\xf3\x99\b\x04\x7f\xdeӺ<\xdc\x14\xf3\xc9Qp\xca\xff\x1fa\x1d\x04\x7f\xa0\x95\x9ecc\t\xbf\xbf\xcfG\x98\x9esqn9\xa9K\xc1[\xaa\x17;\xac\xf2z\xde\\\xa3\x8a\f\xc3bXJcGZ\tj\x84\xf2\x02\x1a\xe8o\x181\xfc\xb5\x9a\u0097\xa3\xfcEu\x11b\xdan_\xe58\xac\xf4\x97\x02\x0e\xd4\xe0\fD\x9e\x14s\xadJع\x1f5\xfbbC\xd5\xf4\xcdN\xe8\xf9_A\v\xf3\x11\xb3\x00/\xd4\xc0ߌ\x03(\xb9\x11\xaa\v\xaa\x8bر[IY kk+$;\x12;zh\x90\xedW\xcc\xf0\x7f\x1b\x17y\x830\x19'\xd5\xc5$F\xdbBk9:ҳ#\x1d\xb5\xbc\x9f\x1c\x055\xe5\xab)\xfe\xb2f\x8d\x85\xe1\xb0\xd2CS\xb6\xfa\xb3kh\xa3$\xf22*\x90\xa3)+\xdbB\xfbKw\x9e\xfe\x94W\xd2^\xe8\xf8\xf7\xa3>\t\x16˭\x99\xcc\xd9q\"\xa2:\xb1+]vh\x9b\x99\xef\x9b[{\xb2\xa3\x8b\xefmG%\xa9\x89$\xf1\xbd\xb5_QGDtm,Bђ\x03mǺ#\xfd\xed\x9fz\x85LʹBT\x95\xa3\xb2\xed\x00\x94F\xce\x7f\xe0\x16\x7fn\x8do\xcb\"n\xaf\x9c\x85XLJ\xad\xdc|\x1c\x98\xa3\xf6\x8d\xd5\x13\b\xb3|\x7fS\xb3xk\x15c]9\xa0\xeb\x10\xf2\x94n\xc9N\x8e\x82k\a\r*@\xf0\r\xeaٯ\x808С\xf2p\xaa\xf62J*\xeca\xfe\xe0\xd1\x10\xe5+\x1b(\xbc\x1bZ*g\x0f\xa3\rj\xa0\xa2n\xccK\x17\xe7S\xf6\xecW$|*\x00[\xc4a1\x9b\xfe\xa7\x8d\t+\x90\x82\\\xe4\xa9\xec\xa8<\xf1ڬ\xbfW\x01W\x0e\x9aI\x1f\x88v\x84\xb5\xfal\xd0Ŕ\x02\xf9\xcc\t\u074b\xfb\x85\x9a\x95\x8b\x86\xf4\xec\x13\"\x19\xef\xed\xeesX\t\x01\xe3\"\xad\x01\xc7\xcba\xd6gK\xe0$Q\x03L\x9e\xde(I\xa7Չ\xcd$N-4\xc8e\xa4\xa2\x9c\xd7\xf4\xc1\xa3s&\xa3\xd8̪4\x96ș\x8c\xe2q;\xc3f\x12{\xf4?X\xb4\xc3-zTz\xc2M\x85\xe0\xa2\xec\xe88\x92Ƹ\x89a\x15Կ\r\xcb\xd40Y\xb2\xfeȵerT\xe6m&\x02\x88W;h\xd5-\x82\xd29\xae\xfd\xa8\xa3)\xc3-Y\xe5=\b\xd2W\xda}<\x9a2\xe4\xfd\xc6\x1aoŻ\x8e\xc9Rw\xe9\t\xd7\x18\xfb\x00Q\xbf\x96@\r\xa5~XԦl\x89\xc3ccY3!\xcd\xe5\xc4Q\xca\xd19p\xdeF\x8d\x89\xe3\xf6\xa5\xa7\x89\xc8MTc\x1c\xb7\xa0\x03\x1f\x99\xe4B\xf7\xfe\xe0\xd1\x10\x03:\x99\x0ec<\x11}\x8eN\x85\xd4-\xd2qM\xf7!\xd67\xa5\x14\x94\x96'2\xea-~\xb6\xacF\xbb\xdc\r\x93\x13r\xf4\x1e\xe9\xc95j\xc1\xbe\xb8\xce\xcf\a*\x94\x9d\xd5\x06b&!\xc6\xc6\x18\xf8EVܘ\x98\xc6\x12)\xb1\xf6\xf8\a\xa2\xee`0\xa8\xb2\xac\xf6ݚ\xf8y\xa4\xf1Ndԣ\xa1\xba\b\xc7R\xac\xf3\x8e\xfc\"*?\x92cͯ\x8c~\xc9pqFI\x84\xe7\x9aWw\xff\xf6Y(\x94\x03\xf5:vY\xb5\bE\xcdƳ?L\xaa+\x12c|\x1f?F\xdd\x03f\xa4\x7f\x8e\x06\x15\x14{Q\x1f\xca#3\x01\xcf\xc6\x12\xc9k\x91b\x9d\x9a\x8e\xb7Q\xd2N\v\x80G'\xdd\x7f\x05\xd0R2R\xe5~\xf1\x8b\x16\x84.\xac\u008d\xa3\xe3\x97^\x96.b\xf9\xe0\xd1\x10`\x11\x9eP\x00\x89\x9b\x1d\x94^ʹ\x15g\x8cJ\x86f\x1b\x04\xbf\xb2\x9a\n\x15\x0f[\x98ϩR\xedk\xe9\xda)\x11wT4\x94\xf6\x92\xb6\xbc\"\xed\xa2G\xcbU\xa8\xcfT}Yo\xa4\xea\x8e㳖I\xbaP?\xd7J\x7f\x8f\x9e7z\x82\x82\x92\xba\xe4}\xab\xbdS6\x93\xa1s\x1c\x97Ƒ}B\xcb\xe6`\xac\x96!\xac]\x88\xba\xb4\x86O\xb9-\xc6e\xd0\xe5\xd4\xe9h\x18\xd5t\xac\xfb\xc3f\x12k*\xb5Z\xbf\x8e\r\xcd)*\xed\xaf\x9e!\xa8&2a\x06\x8a\f3@\xfb+\xfe\xb9\x0f\xca2\xed\xafŁT\x93;\xa1\x17\xdd\x12\xa9\xe5\x9b\x18k:V\x82\rα>\xfb\x15\x00\x00\xc6\xfb'\xfe3\xd5BRM\x85\xd4\xddeb\x10T?\xa5i\xa5pԶdM\x8c#\xedG&\xd9\xd5D\bf2d\x85\xe6A\xb1\xa6E\xeb\xff\"lJ3\xb5y\xd0E\xf4\xf3\x11\xed\xaf\xd10\xa8\xdbp\xd6ٻ\x10\xd5E8\x9eED\xddr&\f\xdd*\xdf\xcaA\x1du\xc5\x03&/*3\x93)g>DP;4#\xe8[D\xc2\xd4\x06\b\xc1!\xac+\x9e\xf0\x10V\x80\x18\xfd\xfb]T\xc2\x14{j_\x16\xc6X3\xf1\x9a\x96ng\xd8L\x06\xdb\x13\xefp\xcb\xf3Z\x1c\x10\x9aCp\xc9+\xden\x9c[j\x7f\\;{ni*\xfe\x04T\xc9jX(\xba\xe5)d5T\x97z\xa3\xc1\xa5\x1e6\xe6\xe8\x04\xa7\x862\xb3m\xd0\x15\xf7\xab\xd8m\xdem\xba)\x94\x15\xef\xf5\xdbj\xa5\r\xd9\x18\xfb\x90\x10\xb6\x8f3\x84-\x02\x8f\x17=\x18Ԍ\x80\x15\x1d\xaf͚\xa9\x12\x14\xc4\xf7\x11\x10\xd3\xf6\xb2~\x94\xa4\x9f\\r\xe5ԅU\x81x~\x0f\x818\xd9y:ʱ\x1c\xa8p8\xfa\x11=\xfe)\xe8\xe1CD\x0f\xe3\xe4\x1e\x955\x7fޗ5m\x89\xde\xccaQ\v\x97\x03\x05}\xbaH\x05ỹâ\x16+\x9d\xc2\xf4\xe1u\xb1}\x93\xb3\xb2\x13\r\xe6[\xf9\x92DȲf\x82V.\xac\xc8a%\xb4\xb9+\x17\xb4+\x11\x8c\x1cZ\xea_\xd0\\\xdd\xfd\x174\v\x85\xf7x\xe0\x9d\xc1\"\xe0\xdc\x1e\x18\x1eq\xdc9\xbe\xcf\u00829\x94M\x0f\b\u07bb\x1d\x17\xca\r\x82&\xdaq#}!\xb8\xbf3\x7f\xda!\x82G\x88\xe3\xdd\xed\x159\x19\xdaN?\xd1R\x12\x8db/\x85)\xccK\x03u~\x8c\xec\xbd\r(:\x9chG\x05iK\x90\\%\xa3_\xc2 (|3\x8c\x03\xab\xbc\x00\xad\x94\xb02\xf5\x00\x12\a\x94\xb25/WAU*\xcc\xeb\x83)dz\xbc\b\r\xc4f2\xa5@>\b2qek\x95v\xe5\xael\x05-W\xa4{g\xd0R#\xe0\xcfh\xa8\xf2\x9a_\xbd\xeby\x8fj\x0f\x1e\x9dR=\x0f\xc1>\xfa4\x96H\x05:\f\xef4z\xed\x14\xa8R\xcaL\x19\xae;\x9e\xa9\f\xc2nca6.)<\xa3$\x92Ue\x10\xe6\xe6\xb02\x15\xca\xf2\x01W\xdf\x1e0\xa3\xf6\xb3J\x16\x8b\xaa\x1e \xce\xf4V\n.\xd5ӕ\x11\xbeE\xa2\x9d\xd7\x04/v\xd0V\xfa(Y/\xdeQ\xbb\x11\xa0\x1d\x05\xefJ{\xec\x9d\x1c\x95J;\b0\x04}.\xc80\x98\x03\x90ͤu\xfc\xa2\x0f\xef\x01\x046K\xedU]\xf3~\xf9\xd0c\x92Ϧo\xc7#a-\x8d\\\x9eu\x13\xfd\xf5$\xa8d\xd3\xe4\xd6\xd2\xd8\xec\xf7\a3\x99\xa9\xe9B\xb1lS\xdb\"*\x8f\xc14%wk}4e\xac8\xb6>P\\\x1e9\xab\xab\xf2\xc0\xfa\x9a\xf5͊<\xfaMPo7Q\x9f\xcdG\x89>e\xbd\x8e\xa5S\a\\UKX7y\xe65\x9b&\xe4\a\x05\x86ŷ\x84\xc5\a\x1d\x15\x9b\x1d\xe1_\x82\xe0Q\t\x91\xb6\xbdEx\xbd[p\x0fDJ*\x8cq\xb2\x04V\x1a\xb8\x82\x9bѢ]\x06\xeb\x90\xe5 C!\x0e\x90\xd88^L \xfch\xca)\x93\x13\xf0o\x84ҷ\xde\xee\x03\f\x93cU\xfa\x8b\xc8a%Z\xb5|\xe8\xa5\x02O\xaa\x15z\xa3F\xee\xe0\xd9\x1e6\xc2\v\x1a\xf4\x7fB\x9c\xa8\x82E-*\xe0\x1b\aZi?A\x95\x12J\x04\xb7\xcc}\xb7s\x9f@\xf0\xefp\xdf\xc7\xc0\x15U\xcb{Z\x92ɨ\x97\xeaF\xd1\a\xe1A\x19\xfd\x9e\x98\b\xe8\xb9\xc0\xf7\x8c߱\x8fw\xcf.\x15[\xfc\xe9~dS0ގ!\xb0:\xf0\xf8\xa6Ɵ\xe0\x15\xba\x02\xb7\xd8J\xdc>\xc8\x1d\x9a\xbf\x95mP\x0fg\rotqh\xdc\v\xb7\x12\xbfwQ\x80\xf7\xbc^\xbf\x8b\x12\xb3\x9c\a쿨U\xfeV\xd6\xc8\v\"\x1a\x7f\xf8\xb5\xa2\xe1\xf2~\x94\x87\x99\xcatx\x85Wp\x0f3\f͞3B\xb28\x8d\x8eu\x85w\xb9q\x1a\x1d\xeb#\x15\xa4\xfd\xc5~\xe4\xbc\xd8\xed\xaf\xf6wt_H\xf7\x97\xfb;\xde\x19齧ǧ\r\x00?\xbe5\xdbM۟\xd7\xdd\xebX\\:1\xda}\x99*\x1f\xe6)If\xb2\xca\x0fwL\x01\x97˒\xa9x٩.\xb8\x03\x15\xe0gg\xc2\xec\x9aEF\xa2\f|\x97A\xe1'\\\x8dL\v\x9eN\xb3\xf9tB\xfd\xd1\x1e\xd4\xff\x8d,\x1c\xa1zoiC\xaa[8\x88\x17\x8f?\xbbgY\xfc\xa1\xde\v\xe5O\xe8\xc5\xe3\xcf\xef\xc2\xf2\x92\x8f~\xbc\a\b\x99\x9b\xb4\xcb\xf8\xa4S\x97~\xb0\v6\xfdڲk)\xeeN|z\xbe<\x87\xe6T(\x9c\xeb~G\x82\xd4\ay\xa7OV\x8eT\xa6\x9dB\xa7Pe\xeeSn\x86U\xaeם\xcc\xc0ʔWt\x82V\xa6ze\xe4oe\x9eZ\xc8\xf4\xae\xef\xe6\xa3\xee~\xba\xb8,\xb5\xca\xe6\x83ezu[Yb\xafv\x88KG\xa5`\x95\xbe\x154\x96Hh\xe9GyZjP\xf1\x97\xa8L\x8d2\xf8\x05\xedU6\x82\x9e\x8a%BI\xca\xf4\xaf,2\xeb%\x15Wb\xb2\x9f\x1cu\xa7fY\xf4\xf5\x1fe\xb8\vu#\xb9/ӹr\x90\x97C\xcbt,\xf0h\xcany\xb7o7o,\xe5߀\xbe\xdc\x11\xacH8\xa4\x99h[\v\x95i#й\x91\x99{\xaaS$\xbdkD\x1b\xd4\xdci\xd9\xd0\xf9\x98\x99\xa6j_\x96f\xe6\x0543Q3\xf3(A\xe5qf\x9a\xab\x9c4\xcf]\xc3]\x11\xe8=ת\xff\xea$\xd1\xc8=I\xa1\xbex\xfc\xc9=\xb3\xb4}\xdf\vc\xd3Xw\xed\xe1\xce,\u05c8\xf7\xae\xf0\xb6\x93\x0f\x97Lj\xe0\xdf\xc6\r<3\xcb0#Por6j\xa6/\xea\x0f\x1e\r\x01\xbf\x93\x03\xa8\xf3\x1c\x06\xebH=[VC\xcb\a\xd3M\xe2𒀈\x9b\xae\xc8L\x8f\xdc\xc0\xc6ع?f\x9c\x81\n4\x19\xa7W+\x00\xff\xedx\v\xf0\xfe\x7f\x8cI\xa0\xe3/}\xb8\x83\xd6\x01\xe7\xdd\xccr\x169y-\xc3\xf9\xed3\x18\x17\xacaz\x90GS\xde\xe7\a̐\xa8k\x1eΜ͠\xc8;^\xee\xf4\x11\x17<\x9c\xfd\x9a!w\xdf\xee<\x03\xaa\xd5\xd4\\\xedp\x11\a=\x80\x8e<\xce\t\xe6\xfa\xf8\xeeA]\xe0\x19\xe0,\xa8\xc6~\x12\x7fYC\x89\xc9k7\x8fH\x8d%6\x13\xf3\xb7\xd0\xc6\xeaD\xeb\x8b\t\xed\x9f\x1cu\xcf\x00}/\xb9Ys\x90\xb3\x00\x05k\x1a\x13*n,\x81ϯ\t%#Ô\xda\xcch\xff@as\xe5\x93\x18T\a\x877\xa8\xb8\x83\xd6\x01\xe3Bh8+=\x83\xfe\a\xac\xf4\f\xbei\xa5q&u\x06{B\x93:\x83>\xa9I\xf5`\x9fʤΐ\xbbM\xea8+\x8eC\xdbN\xb3\xf5\x06\xb0\xfd[\xa7azS\xf5\x93>\x88|g\xbfE\x98ÙL\x8fF\x19\x93\xb0\r\xd8F\x8c\xfd\xf97\x00\x00\x00\xff\xff\x01\x00\x00\xff\xffW7\x91<\f84\x04X\x85'$@\xe4d\a\xa5W1\xed\xc5\x19\x83\x92\xa1\xd8&F\xaf\xa4\xc6L\xc6\xc3V\xe6s\xae\x94\xebZ\xda\xf7\xa4\x88[*\x18J\xfb\x18\xf7\xb4B\xfd\xaaE\xc3U\xc8\xc7X>\xd9~I\xd65\xfcYO\x04^\xa9\x9f}K=\x0f\xee7j\x80\f\xa3\xba\xa4c\xaf\xbcS\xb2\x90\xbes\x1c\x96FN>\xc2e{\xd2VK\x13\xc6.\x04]Z\xcd'\xdd\x16\xed2\xa8rlwԌr8\xc6\xfd!\v\x99j*\x95Z\xbf\nu\xcd**\x1e\xaf\x8e!\xa8f2b\x06\x8a\f3\x80\xc7k\xfau\x1f\x94e<^\x8b\x13\xaafwBM\xba!b\xd373֘W\x8cLֱ>\xbb\x15\x00\x00\xc6\xfbg\xfe3VBRͅ\xd8\xd9ef`X]\xa5)\xa5\xb0Ծd͌\x1c\x8f\x9c\br\xd5\x11\x82\x85\xf4Y\xa1y\x90\xacq\xd1\xfaL\x80Mj\xa62\x0f\xaa\x98|}\x84\xc7k0\fj\x17\x9c\f\xe6,\x84U\x11\xf6g\x15Q7\x9c\x11C\xb7ɷ\xb1Q\a]q\x8fɉ\xca,d̙\xf7\x11\xe4\n-\b\xea\x14\x111\xb5\x1e\x82\xb7\t\xab\x8a'܄%`\x8a\xfe\xfd!(a\x92=\xb6.+c\xac\x98h\x8dK\xbb2d!\xbd\xe5\t7\xb8\xe7y\xad6\b\xc5\xc1\xa8\xa0\x15\xedw\xf6-\xb9>\xf6=\xb3o)*|\x05T\x89jZ)\xba\xe1)D5U\x97z\xe7\x85K=\xed\x8c\xd1\nN\refߠK\ue5e1Ӽ]t](+:\xaa\xbb\xd5J\x192\x1ez\x10\x11\xb6\x0f3\x84-\x00\x9f.z0\xa8\x19\x00+\x06Z\xeb9\x93%(\x88\xef&@\xcc\xcbKF.\xd08\xbb\xe4ҩ\xf3\xab<\xf1\xfcA\x02\xe2l\xe71\x17\xbc\x9c0\xb38\xea\x12=\xfc\xc8k\xe1\xfd\x84\x16\xf8\xec\x1e\x955}>\x965\xee\x91Z\xcciU\v\xa7#\t\xba\xbb\b\tẹӪ6U:\x99n\xc3ib\xff$gd'\x18\xcc7\xf2%\x10\x13eM\x18\xaelX\x91\xc2Jhs7\x0ehW\xc4\b:\xf5\xd8=\xa0ٺ\xfb\x0fh\x06*\xdd\xe3\x81g\x06\x83\x90\xe6\xf6\xc0\xf0\x88\xe5\xce\xf1}V\x16̢\xecz@\xf0\xdcm\xb9\x92\xdc h\xa2-w\xa2/\x04\xd7w\xe1\x8f;Dp\v\xb1\xbc\x87\xbd\"+C\xfb\xe9'JJ\x82Q\xec\xb50\xf9yi\xa0\u038d\x91\xbd\xb3\x03\x85\xa7\x0e\x0f\x98\xa1\xbe\x04\xc9U\"\xf8\xc4\x0f\x82\xc2;\xc30\xb0\xcc\vPJ\t+c\x17 a@!z}s\xe5U\xc5¼.\x98t<\xcb\vS@d!c\n\xe4\x82$&\xae\xec\xcdҡܕ\xbd\xa0\xe5\x86t\x1f\fZ*\x84\xf4=\x1a\xaa\xbc\xe2\x97\xf7zΥڃC\xc7T\xcfA0\x97>\xad!b\x81\x0e\xcd;\xf7^9\x05\xb2\x143S\x9a\xeb\x8ek*\x8dp\xd8X腋\n\x0f\x17H\x90\xaa\xf4\xc2\xdc\x14V\xc6BY.\xe0\xe6\xddCJ\xafݬ\x92դ\xca\v\x883\xbe\x95\x8c\nyu\xa5\x85o\x95h缒.v\xd0V\xba(Y7\xdeA\xbb\xe1\xa15\x8c\x0e\xa5\xd9\xf6:K\xc5\xd2\x0e<\f\x86\x9f34Mz\x03$\vi\x1c\xbf\xe0Ż\a\x91\x9a\xa5\xf6\xb2\xae鸾\xe8\xd1\xc9g\xf3\xb3\xa6A\xa4ǁózE=\xed\x18\x16d\x1e\xdcV\x1a\x9by\xfe\xa0\a3\xbf\xbaR,\xf3\xaay#(\x8f\xde0\x05\xb5s\xdd\xe8r\xaa8\xf6.PX\x1e)\xa9\xab\xf2Dƚ\x8c\xed\x86<\xba\xaf$\xdd\xdd\x04}6\x17%x\x95\xf5*\x94N\xedqU=\"\xc3\xec\x99\xd7d\x1e\x90\x1b\x14\x98V\xcf\"\x16\x1f4T\xec6\x94~\x13\x04\xb7J\x88\xb4\xef-\xc2\xe3݊{BB`\xa6\x8d\x93!R\xa5\x81J\xb8\x05-ؤ7\x0fY\x0e2\x14b\x0f\x89p~с\xf0F\x97c&\xc7\xe3\xdf\t\xa5\xef\xdd\xdd{\x18:Ǫt'\x91\xc2\xcad\xd5r\xa1\xd7\n<\xab\x96\xef\x8dj\xb9\x83{\xbb\xffR\xba\xa0A\xff\xc7\xc7\t*XТ\x02>>\xe1J\xf9\t\xb2\x14Q\"\xb8d\xf6\xb9\x19\xfb\f\x92~\x0f\xf7\xc3\x14\xb8\xa2\xea\xe9\x88K4\x1b\xf5R\x9e(F/<(\x82\xcf#\x03\x01-\x17\xe9-\xa7\xaf؇\x87G\x17\x8b-\xfe\xfc8\xb2.hoG\x13\xa9:\xf0\xf8\xba\u009f\xe1%\xba\x047\xd8R\xdc\xde\xcb횻\x94\xbdW\x0fG\rOta\xe8\xb4\x1bn)~o'\x01\xdes{\xfdv\x92\x98\xe5\\`\xffM\xce\xf2w\xb2z^ ֺݯ%\r\xa7\xf7\x83<\xccX\xa6\xc3\xcbt\x05w0\xfd\xd0\xec9#$\x9b\xa6ѡ\xa6\xd2]\xee4\x8d\x0e\xb5\x11\v\xd2\xfe\xea8r^\xec\xf67\xc7\x1b\xba/\xa4\xfb\xeb\xe3\r\x1f\x8c\xf4\xde\xd3\xe2\xd3\x06\x80\x1f\xdfX\xec\xa6i\xcfi\xeeU(.\x1d\xe9\xed\xb1L\x95\xf7\xf3\x94$3Y\xe5\xc7\a\x86\x90\x96˒\xa9x٩.i\x1b*\xc0\xcf΄94\x8a\x8cD\x19x/\x93\x84\x1fq52-x<\xcd\xe6\x93\x19\xf5'GP\xff?\xb2p\x98l\xbd\xc7-\xaan~'^<\xfe\xe2\x9eiq\xbbz/\x94;\xa0\x17\x8f\xbf\xbc\v\xcbI>\xfa\xe9\x11\xa0\xc4ܤC\xc6'\x9e\xba\xf4\xa3C\xb0\xf1ۖCSqw\xe2\xd3\xf3\xf5>\xb4\xa4B\xa5\xb9\xeew$H\xbd\x97\xb7\xfbd\xe5Heک\xe4\x14\xaa\xccu\xcaͰ\xca\xf5\xba\xa3\x19X\x99\U0009a720\x95\xa9^\x19\xf9[\x99\xbbVbz\xd7\xf7\xf3Q\x0f_]\\\xd6Ze\xf2\xc12\xbd\xba\xbd,\xb1\x97\a\xc4e\xc0\x82\x91J\x9d\nZCD\xb4\xf4\x83<-ը釨L\x8d\xd2\xf8\x05\x1ee6\x82\x1a\x8a!|I\xca\xf4\xaf\f2\x19\x05fW\xa4\xb3\x9f,u\xa7f\x19\xf4\xed\x8f2܅\xba\x93ܗ\xe9\\Y\xc8˩'*\x16\xd8貝\xde\xfd\xd3\xcdkk\xf9נ\x1f\x1f\bVD\x1c\xd2L\xb4\xbd\x89ʴ\x11ɹ\x91\x99k\xaaR$\x9dcD\xef\xd5\xdciْ\xf313Mձ,\xcd\xcc\x03hf\xa2f\xe6V\x92\x94Ǚi\xaer\xd2<\x0fuwC\xa0\x8f\x1c\xab\xfe\xa7\x93D\x03\xe7$\x89\xfa\xe2\xf1g\xf7\x8cҴ}/\x8cIc=\xb4\x86\a\xb3\\\x03\u07bb\xc4\xdbO>\\3Ɏ\x7f7\xad\xe3\x99Y\x86\x19\x81z\x9d\xb3Q\x13uP\x7fph\b\xf8\xbd\x1c@\x95\xe70\x19G\xeaٺ\x1aZ>\x98n\x12\x86\x17\bD\xdcTEfz\xe4\x0ev\x8a\x9d\xfbs\xc6\x1e(A\xa3qz9\x03\xf0o\xc7{\x80\xf7\xff1&\x82\x9e~\xe8K\xdbh-p\xde\xc9,g\x92\xa3Dz4\xbf}\x01\xa3\x8c\xb4Du\xb2\xd1\xe5c~\xc0\x02\x99t\xccK3g\vh\xe2\x19/w\xf8\t\a\xbc4\xfb\xb5@\x1e>\xdd9\x06T\xa9\xa9>ڥE\x1cT\a\x06\xf4\xb8$\x98\xab\xed{\x04u\x9eg\x90fA\x15\xf6\x93\xf8\xcb\n\x8a\xcd^\xbb\xbeDj\r\xb1\x9b\x98\xbf\x87ƫ\x0e\xd7\x17\x1d\xda\xef,uO\a]/\xb9\xddr\x90\xb3\x00\x19i[\x1d*n\r\x91\x9e_\xe3KF\x86)5\x99\xd1\xee\x86B\x96\xca'1\xa8\x16.ݠ\xa6m\xb4\x168-\x84\x96f\xa5\x17\xd0\xff\x82\x95^\xc0w\xadt\x9aI]\xc0\x9eФ.\xa0OjR\x1dا2\xa9\v\xe4a\x93\xca\x17űh\xfbi\xb6N\a\xf6\xbfu\xea\xa77\x85\xf3\xb4\xd4\xf7.#yZ\xfeK\xe9i\x900\x9b\xdf\xc7)*2u\x98\t\xfc\xa8\xd3S\x1cz\x9e=|\xba\t\xed\xa0\xc3\\+\x00t\xc6gs\x11\xd0\xe8r,\xc1\x0e (RK\x84*\xbb\x89c0\xb3\x11r\v$\xf4\xe2\xabb\xf8~̈́ꂩ\xb6\xd8D\xf5\xba\x9ap\xf5\xdaj\x19\xe4k\xe61\xa91\x17\x8c\xde6>-)\xffᣞo\xfc{\xce\x1b\x82\xecb9\xa0ʘ_C\u0089\x84\xa9\xd6\x00Gg2\x975V\x9f:>\xbb\x15˪\xee\v\xae\x8f\xb9:\x93\xa8\x04C\xcc\x04iH\x85\x04.\x19\xbe\xd2\xca\xcd*_\xfdO,\xf8r\xfa\xc7Nafn\x18\xaf`\x18qm\xa8\x1b]\x86\xd3\a\xff\x81\xb8\x89t\xa5g\xe73D\x0e\xed\xff\x03'\x13\xeft\xf3\xf1\x02\x1f$N\x1c+nj\xe88q\xa3˩&\xef\x1a\x86\x84 Date: Wed, 4 Nov 2026 17:16:57 -0600 Subject: [PATCH 3/8] cli: add miren auth revoke and miren auth revocation list Serials circulate in two formats: audit logs print decimal, openssl prints hex. Parsing one as the other succeeds and revokes a certificate that does not exist, so digits alone mean decimal, 0x or any hex letter means hex, and output echoes both forms so a wrong guess is visible. A revoked certificate gets its own diagnostic. The generic access-denied text sends people to `miren login`, which can never fix this. --- cli/commands/auth_revoke.go | 162 ++++++++++++++++++++++ cli/commands/auth_revoke_test.go | 43 ++++++ cli/commands/commands.go | 8 ++ cli/commands/rpc_errors.go | 12 ++ cli/commands/rpc_errors_test.go | 6 + docs/command-sidebar.json | 5 +- docs/docs/command/auth-revocation-list.md | 32 +++++ docs/docs/command/auth-revocation.md | 23 +++ docs/docs/command/auth-revoke.md | 46 ++++++ docs/docs/command/auth.md | 2 + docs/docs/commands.md | 3 + pkg/rpc/revocation_test.go | 4 +- servers/certrevocation/server_test.go | 4 +- 13 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 cli/commands/auth_revoke.go create mode 100644 cli/commands/auth_revoke_test.go create mode 100644 docs/docs/command/auth-revocation-list.md create mode 100644 docs/docs/command/auth-revocation.md create mode 100644 docs/docs/command/auth-revoke.md diff --git a/cli/commands/auth_revoke.go b/cli/commands/auth_revoke.go new file mode 100644 index 000000000..49579c594 --- /dev/null +++ b/cli/commands/auth_revoke.go @@ -0,0 +1,162 @@ +package commands + +import ( + "fmt" + "math/big" + "strings" + + "miren.dev/runtime/api/certrevocation/certrevocation_v1alpha" + "miren.dev/runtime/pkg/rpc" + "miren.dev/runtime/pkg/ui" +) + +type revocationJSON struct { + Serial string `json:"serial"` + Decimal string `json:"serial_decimal"` + RevokedAt string `json:"revoked_at"` + RevokedBy string `json:"revoked_by"` + Reason string `json:"reason,omitempty"` +} + +func newRevocationJSON(r *certrevocation_v1alpha.Revocation) revocationJSON { + return revocationJSON{ + Serial: r.Serial(), + Decimal: serialDecimal(r.Serial()), + RevokedAt: r.RevokedAt(), + RevokedBy: r.RevokedBy(), + Reason: r.Reason(), + } +} + +// parseCertSerial normalizes a serial as an operator would paste it. Two formats +// are in the wild and nothing in a serial says which it is: audit logs print +// decimal, while openssl prints hex, sometimes with colons. Reading one as the +// other would succeed and revoke a certificate that does not exist, so digits +// alone mean decimal, as a hex serial without a letter is vanishingly unlikely +// at 128 random bits, and a 0x prefix settles any doubt. Callers show the result +// in both bases so a wrong guess is visible before it counts. +func parseCertSerial(input string) (string, error) { + s := strings.TrimSpace(input) + if s == "" { + return "", fmt.Errorf("certificate serial is required") + } + + if strings.IndexFunc(s, func(r rune) bool { return r < '0' || r > '9' }) == -1 { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return "", fmt.Errorf("invalid certificate serial %q", input) + } + s = n.Text(16) + } else { + s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") + s = strings.ReplaceAll(s, ":", "") + } + + return rpc.ParseCertSerial(s) +} + +func serialDecimal(serial string) string { + n, ok := new(big.Int).SetString(serial, 16) + if !ok { + return "" + } + return n.String() +} + +func AuthRevoke(ctx *Context, opts struct { + Serial string `position:"0" usage:"Certificate serial, in hex or decimal" required:"true"` + Reason string `short:"r" long:"reason" description:"Why the certificate is being revoked"` + Yes bool `short:"y" long:"yes" description:"Skip confirmation prompt"` + FormatOptions + ConfigCentric +}) error { + serial, err := parseCertSerial(opts.Serial) + if err != nil { + return err + } + + // JSON output has to stay machine-readable, so it never prompts. + if opts.IsJSON() && !opts.Yes { + return fmt.Errorf("--format json requires --yes") + } + confirmed, err := confirmDeletion( + fmt.Sprintf("Revoke certificate %s (decimal %s)? This cannot be undone from the CLI.", serial, serialDecimal(serial)), + opts.Yes, false, false) + if err != nil { + return err + } + if !confirmed { + return nil + } + + client, err := ctx.RPCClient(string(rpc.ServiceCertRevocations)) + if err != nil { + return err + } + defer client.Close() + + res, err := certrevocation_v1alpha.NewCertRevocationsClient(client).Revoke(ctx, serial, opts.Reason) + if err != nil { + return err + } + + if opts.IsJSON() { + return PrintJSON(struct { + revocationJSON + AlreadyRevoked bool `json:"already_revoked"` + }{newRevocationJSON(res.Revocation()), res.AlreadyRevoked()}) + } + + if res.AlreadyRevoked() { + ctx.Printf("Certificate %s was already revoked on %s\n", serial, res.Revocation().RevokedAt()) + return nil + } + + ctx.Completed("Revoked certificate %s (decimal %s)", serial, serialDecimal(serial)) + // The cluster has no record of what it issued, so success proves only that + // this serial is now refused, not that it was the certificate you meant. + fmt.Fprintln(ctx.Stderr, "Warning: the cluster keeps no record of issued certificates, so it cannot confirm this serial belongs to a real one. Check it against the certificate or audit log.") + return nil +} + +func AuthRevocationList(ctx *Context, opts struct { + FormatOptions + ConfigCentric +}) error { + client, err := ctx.RPCClient(string(rpc.ServiceCertRevocations)) + if err != nil { + return err + } + defer client.Close() + + res, err := certrevocation_v1alpha.NewCertRevocationsClient(client).List(ctx) + if err != nil { + return err + } + revocations := res.Revocations() + + if opts.IsJSON() { + items := make([]revocationJSON, 0, len(revocations)) + for _, r := range revocations { + items = append(items, newRevocationJSON(r)) + } + return PrintJSON(items) + } + + if len(revocations) == 0 { + ctx.Printf("No client certificates have been revoked\n") + return nil + } + + headers := []string{"SERIAL", "REVOKED", "BY", "REASON"} + var rows []ui.Row + for _, r := range revocations { + rows = append(rows, ui.Row{r.Serial(), r.RevokedAt(), r.RevokedBy(), r.Reason()}) + } + table := ui.NewTable( + ui.WithColumns(ui.AutoSizeColumns(headers, rows, ui.Columns().NoTruncate(0))), + ui.WithRows(rows), + ) + ctx.Printf("%s\n", table.Render()) + return nil +} diff --git a/cli/commands/auth_revoke_test.go b/cli/commands/auth_revoke_test.go new file mode 100644 index 000000000..761964c6d --- /dev/null +++ b/cli/commands/auth_revoke_test.go @@ -0,0 +1,43 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCertSerial(t *testing.T) { + const hex = "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64" + const decimal = "105147180391496216627680129114540289636" + + tests := []struct { + name string + input string + want string + }{ + {"uppercase hex, as in openssl serial output", "4F1A9C3E7B2D8A6F0C5E1D3B9A7F2E64", hex}, + {"lowercase hex", hex, hex}, + {"colon-separated hex, as in openssl -text", "4f:1a:9c:3e:7b:2d:8a:6f:0c:5e:1d:3b:9a:7f:2e:64", hex}, + {"0x prefix", "0x" + hex, hex}, + {"decimal, as in the audit log", decimal, hex}, + {"surrounding whitespace from a paste", " " + hex + "\n", hex}, + {"0x forces hex on an all-digit serial", "0x1234", "1234"}, + {"all digits read as decimal", "1234", "4d2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCertSerial(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + + for _, bad := range []string{"", " ", "0", "-1", "xyz", "0x"} { + _, err := parseCertSerial(bad) + require.Error(t, err, "input %q", bad) + } +} + +func TestSerialDecimal(t *testing.T) { + require.Equal(t, "1234", serialDecimal("4d2")) +} diff --git a/cli/commands/commands.go b/cli/commands/commands.go index 2d21eb0ec..103c8ec57 100644 --- a/cli/commands/commands.go +++ b/cli/commands/commands.go @@ -1231,6 +1231,14 @@ miren deployment create --format jsonl | jq -c 'select(.event == "build_step")' Body: "miren auth generate", }), )) + d.Dispatch("auth revoke", Infer("auth revoke", "Revoke a client certificate so the cluster refuses it", AuthRevoke, + WithExample(mflags.Example{ + Name: "Revoke a leaked certificate", + Body: "miren auth revoke 4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64 --reason \"leaked\"", + }), + )) + d.Dispatch("auth revocation", Section("auth revocation", "Inspect revoked client certificates", "")) + d.Dispatch("auth revocation list", Infer("auth revocation list", "List revoked client certificates", AuthRevocationList)) d.Dispatch("auth ci", Section("auth ci", "CI authentication binding management", "")) d.Dispatch("auth ci add", Infer("auth ci add", "Add a CI authentication binding to an application", AuthCIAdd)) d.Dispatch("auth ci list", Infer("auth ci list", "List CI authentication bindings for an application", AuthCIList)) diff --git a/cli/commands/rpc_errors.go b/cli/commands/rpc_errors.go index d288c4b25..5fb84455e 100644 --- a/cli/commands/rpc_errors.go +++ b/cli/commands/rpc_errors.go @@ -287,6 +287,18 @@ func (c *Context) diagnoseResolve(re *rpc.ResolveError) *ui.Diagnostic { } } + // Same wrong turn as above: this certificate will never work again, and + // nothing about a session or a sign-in is at fault. + if re.StatusCode == 401 && re.Code == rpc.AuthErrorCertRevoked { + return &ui.Diagnostic{ + Summary: fmt.Sprintf("this client certificate was revoked on %s", cluster), + Detail: "The cluster refuses this certificate for good, so retrying won't " + + "help. Ask a cluster operator for a new one.", + Facts: []ui.Fact{{Label: "Cluster reported", Value: re.Detail}}, + Cause: fmt.Errorf("%w: %w", ErrAccessDenied, re), + } + } + if re.StatusCode == 401 { return &ui.Diagnostic{ Summary: fmt.Sprintf("access denied on %s", cluster), diff --git a/cli/commands/rpc_errors_test.go b/cli/commands/rpc_errors_test.go index ea25a55f3..29780053f 100644 --- a/cli/commands/rpc_errors_test.go +++ b/cli/commands/rpc_errors_test.go @@ -63,6 +63,12 @@ func TestDiagnoseResolveDistinguishesKinds(t *testing.T) { wantSummary: `access denied on cluster "local"`, wantDetail: []string{"permission"}, }, + { + name: "revoked certificate", + err: rpc.NewResolveStatusErrorWithReason("entities", "localhost:8443", 401, rpc.AuthErrorCertRevoked, "client certificate abc has been revoked"), + wantSummary: `this client certificate was revoked on cluster "local"`, + wantDetail: []string{"new one"}, + }, { name: "unexpected status", err: rpc.NewResolveStatusError("entities", "localhost:8443", 404), diff --git a/docs/command-sidebar.json b/docs/command-sidebar.json index f257b088e..6880373ac 100644 --- a/docs/command-sidebar.json +++ b/docs/command-sidebar.json @@ -82,7 +82,10 @@ "command/auth-provider-add-password", "command/auth-provider-list", "command/auth-provider-remove", - "command/auth-provider-show" + "command/auth-provider-show", + "command/auth-revocation", + "command/auth-revocation-list", + "command/auth-revoke" ] }, { diff --git a/docs/docs/command/auth-revocation-list.md b/docs/docs/command/auth-revocation-list.md new file mode 100644 index 000000000..0f61f10df --- /dev/null +++ b/docs/docs/command/auth-revocation-list.md @@ -0,0 +1,32 @@ +--- +title: "miren auth revocation list" +sidebar_label: "auth revocation list" +description: "List revoked client certificates" +--- + +# miren auth revocation list + +List revoked client certificates + +## Usage + +```bash +miren auth revocation list [flags] +``` + +## Flags + +- `--cluster, -C` — Cluster name +- `--config` — Path to the config file +- `--format` — Output format (text, json) (default: `text`) +- `--json` — Shorthand for --format json + +## Global Options + +- `--options` — Path to file containing options +- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`) +- `--verbose, -v` — Enable verbose output + +## See also + +- [`miren auth revocation`](./auth-revocation.md) diff --git a/docs/docs/command/auth-revocation.md b/docs/docs/command/auth-revocation.md new file mode 100644 index 000000000..7c15780cc --- /dev/null +++ b/docs/docs/command/auth-revocation.md @@ -0,0 +1,23 @@ +--- +title: "miren auth revocation" +sidebar_label: "auth revocation" +description: "Inspect revoked client certificates" +--- + +# miren auth revocation + +Inspect revoked client certificates + +## Usage + +```bash +miren auth revocation [flags] +``` + +## Subcommands + +- [`miren auth revocation list`](./auth-revocation-list.md) — List revoked client certificates + +## See also + +- [`miren auth`](./auth.md) diff --git a/docs/docs/command/auth-revoke.md b/docs/docs/command/auth-revoke.md new file mode 100644 index 000000000..8784611c8 --- /dev/null +++ b/docs/docs/command/auth-revoke.md @@ -0,0 +1,46 @@ +--- +title: "miren auth revoke" +sidebar_label: "auth revoke" +description: "Revoke a client certificate so the cluster refuses it" +--- + +# miren auth revoke + +Revoke a client certificate so the cluster refuses it + +## Usage + +```bash +miren auth revoke [flags] +``` + +## Arguments + +- `serial` — Certificate serial, in hex or decimal + +## Flags + +- `--cluster, -C` — Cluster name +- `--config` — Path to the config file +- `--format` — Output format (text, json) (default: `text`) +- `--json` — Shorthand for --format json +- `--reason, -r` — Why the certificate is being revoked +- `--yes, -y` — Skip confirmation prompt + +## Global Options + +- `--options` — Path to file containing options +- `--server-address` — Server address to connect to (default: `127.0.0.1:8443`) +- `--verbose, -v` — Enable verbose output + +## Examples + +**Revoke a leaked certificate:** + +```bash +miren auth revoke 4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64 --reason "leaked" +``` + +## See also + +- [`miren auth`](./auth.md) diff --git a/docs/docs/command/auth.md b/docs/docs/command/auth.md index 2f9010c88..1d3c40e1b 100644 --- a/docs/docs/command/auth.md +++ b/docs/docs/command/auth.md @@ -19,3 +19,5 @@ miren auth [flags] - [`miren auth ci`](./auth-ci.md) — CI authentication binding management - [`miren auth generate`](./auth-generate.md) — Generate authentication config file - [`miren auth provider`](./auth-provider.md) — Identity provider management +- [`miren auth revocation`](./auth-revocation.md) — Inspect revoked client certificates +- [`miren auth revoke`](./auth-revoke.md) — Revoke a client certificate so the cluster refuses it diff --git a/docs/docs/commands.md b/docs/docs/commands.md index 64562378e..0d4eb284b 100644 --- a/docs/docs/commands.md +++ b/docs/docs/commands.md @@ -83,6 +83,9 @@ Complete reference for all `miren` CLI commands. | [`miren auth provider list`](./command/auth-provider-list.md) | List identity providers | | [`miren auth provider remove`](./command/auth-provider-remove.md) | Remove an identity provider | | [`miren auth provider show`](./command/auth-provider-show.md) | Show an identity provider | +| [`miren auth revocation`](./command/auth-revocation.md) | Inspect revoked client certificates | +| [`miren auth revocation list`](./command/auth-revocation-list.md) | List revoked client certificates | +| [`miren auth revoke`](./command/auth-revoke.md) | Revoke a client certificate so the cluster refuses it | ## cloud diff --git a/pkg/rpc/revocation_test.go b/pkg/rpc/revocation_test.go index 940d85d59..4d392ca62 100644 --- a/pkg/rpc/revocation_test.go +++ b/pkg/rpc/revocation_test.go @@ -295,9 +295,9 @@ func TestCertSerialIsCanonical(t *testing.T) { } func TestParseCertSerial(t *testing.T) { - serial, err := rpc.ParseCertSerial("17CAFA9B5C6FE138F6E0113E5686A658") + serial, err := rpc.ParseCertSerial("4F1A9C3E7B2D8A6F0C5E1D3B9A7F2E64") require.NoError(t, err) - require.Equal(t, "17cafa9b5c6fe138f6e0113e5686a658", serial) + require.Equal(t, "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64", serial) for _, bad := range []string{"", "0", "-1", "+1", "zz", "1:2", strings.Repeat("f", 41)} { _, err := rpc.ParseCertSerial(bad) diff --git a/servers/certrevocation/server_test.go b/servers/certrevocation/server_test.go index 1a861f2a9..665f3196c 100644 --- a/servers/certrevocation/server_test.go +++ b/servers/certrevocation/server_test.go @@ -10,7 +10,7 @@ import ( "miren.dev/runtime/pkg/rpc" ) -const leaked = "17cafa9b5c6fe138f6e0113e5686a658" +const leaked = "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64" func newTestServer(t *testing.T) (*certrevocation_v1alpha.CertRevocationsClient, *rpc.CertRevocations) { t.Helper() @@ -31,7 +31,7 @@ func TestRevoke(t *testing.T) { c, set := newTestServer(t) ctx := operator() - first, err := c.Revoke(ctx, "17CAFA9B5C6FE138F6E0113E5686A658", "leaked into a transcript") + first, err := c.Revoke(ctx, "4F1A9C3E7B2D8A6F0C5E1D3B9A7F2E64", "leaked into a transcript") require.NoError(t, err) require.False(t, first.AlreadyRevoked()) require.Equal(t, leaked, first.Revocation().Serial()) From f99c525b94a9d98e7b06ab2b9805c6874512734f Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Wed, 4 Nov 2026 17:19:29 -0600 Subject: [PATCH 4/8] runner: enforce certificate revocations and keep exec to the coordinator Runner listeners trust the same CA as the coordinator, so enforcing revocation only there left a revoked certificate able to dial a runner directly and read metrics or exec into its sandboxes. Runners now watch the same revocations and enforce them on their own listener. They have no store to load from at boot, so they refuse certificates until the first snapshot arrives, and a bad record in that snapshot leaves them unloaded, matching the coordinator failing to boot. A revocation reaches a runner a moment after Revoke returns. Exec and attach now also accept only the coordinator, whose proxy is how every legitimate call arrives. Without that, any unrevoked client certificate could reach a sandbox past the checks the proxy applies. --- components/runner/nodeadmin.go | 24 +++++++++++++++ components/runner/nodeadmin_test.go | 46 +++++++++++++++++++++++++++++ components/runner/runner.go | 18 ++++++++--- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/components/runner/nodeadmin.go b/components/runner/nodeadmin.go index e43899ab2..a8cdd5afc 100644 --- a/components/runner/nodeadmin.go +++ b/components/runner/nodeadmin.go @@ -6,6 +6,7 @@ import ( "log/slog" "sync" + "miren.dev/runtime/api/exec/exec_v1alpha" "miren.dev/runtime/api/nodeadmin/nodeadmin_v1alpha" "miren.dev/runtime/components/diskio" "miren.dev/runtime/pkg/lbdmod" @@ -84,6 +85,29 @@ func (s *nodeAdminServer) InstallDiskAccelerator(ctx context.Context, req *nodea return nil } +// coordinatorOnlyExec confines a runner's exec service to the coordinator, whose +// exec proxy is how every legitimate call arrives. Any certificate from the +// cluster CA reaches this listener and it has no authorizer, so otherwise every +// client certificate could exec into any sandbox on a runner it can reach, +// bypassing the checks the coordinator's proxy applies. +type coordinatorOnlyExec struct { + exec_v1alpha.SandboxExec +} + +func (e coordinatorOnlyExec) Exec(ctx context.Context, state *exec_v1alpha.SandboxExecExec) error { + if err := requireCoordinator(ctx); err != nil { + return err + } + return e.SandboxExec.Exec(ctx, state) +} + +func (e coordinatorOnlyExec) Attach(ctx context.Context, state *exec_v1alpha.SandboxExecAttach) error { + if err := requireCoordinator(ctx); err != nil { + return err + } + return e.SandboxExec.Attach(ctx, state) +} + // requireCoordinator refuses anyone but the coordinator. // // The listener verifies client certificates against the cluster CA. The diff --git a/components/runner/nodeadmin_test.go b/components/runner/nodeadmin_test.go index 63f48da2f..667744133 100644 --- a/components/runner/nodeadmin_test.go +++ b/components/runner/nodeadmin_test.go @@ -2,12 +2,15 @@ package runner import ( "context" + "crypto/x509" + "encoding/pem" "log/slog" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "miren.dev/runtime/api/exec/exec_v1alpha" "miren.dev/runtime/api/nodeadmin/nodeadmin_v1alpha" "miren.dev/runtime/clientconfig" "miren.dev/runtime/pkg/caauth" @@ -48,6 +51,36 @@ func TestRequireCoordinatorAcceptsTheCoordinator(t *testing.T) { &rpc.Identity{Method: rpc.AuthMethodCert, Subject: rpc.CoordinatorCertSubject}))) } +type recordingExec struct{ calls int } + +func (r *recordingExec) Exec(context.Context, *exec_v1alpha.SandboxExecExec) error { + r.calls++ + return nil +} + +func (r *recordingExec) Attach(context.Context, *exec_v1alpha.SandboxExecAttach) error { + r.calls++ + return nil +} + +func TestRunnerExecAcceptsOnlyTheCoordinator(t *testing.T) { + coordinator := rpc.ContextWithIdentity(context.Background(), + &rpc.Identity{Method: rpc.AuthMethodCert, Subject: rpc.CoordinatorCertSubject}) + operator := rpc.ContextWithIdentity(context.Background(), + &rpc.Identity{Method: rpc.AuthMethodCert, Subject: "miren-user"}) + + inner := &recordingExec{} + exec := coordinatorOnlyExec{inner} + + require.ErrorContains(t, exec.Exec(operator, nil), "only the coordinator") + require.ErrorContains(t, exec.Attach(operator, nil), "only the coordinator") + require.Zero(t, inner.calls, "a refused call must not reach the exec server") + + require.NoError(t, exec.Exec(coordinator, nil)) + require.NoError(t, exec.Attach(coordinator, nil)) + require.Equal(t, 2, inner.calls) +} + func TestRunnerListenerNeedsAClusterCA(t *testing.T) { access, err := NewClusterAccess(slog.Default(), RunnerDeps{}, RunnerConfig{ Id: "runner", DataPath: t.TempDir(), ListenAddress: "localhost:0", @@ -125,6 +158,10 @@ func TestNodeAdminAuthenticatesCoordinatorOverWire(t *testing.T) { return nodeadmin_v1alpha.NewNodeAdminClient(cl).InstallDiskAccelerator(t.Context(), "foreign/image:tag", false) } + _, err = call(coordinatorCert.CertPEM, coordinatorCert.KeyPEM) + require.Error(t, err, "certificates wait for the revocation list to arrive") + access.certRevocations.Load(nil) + // The foreign image stops authorized callers before any host work. result, err := call(coordinatorCert.CertPEM, coordinatorCert.KeyPEM) require.NoError(t, err) @@ -137,6 +174,15 @@ func TestNodeAdminAuthenticatesCoordinatorOverWire(t *testing.T) { _, err = call(forgedCert.CertPEM, forgedCert.KeyPEM) require.Error(t, err) + // This listener applies the same revocations as the coordinator's. + block, _ := pem.Decode(coordinatorCert.CertPEM) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + access.certRevocations.Add(rpc.CertSerial(cert.SerialNumber)) + _, err = call(coordinatorCert.CertPEM, coordinatorCert.KeyPEM) + require.ErrorContains(t, err, "revoked") + _, err = call(nil, nil) var denial cond.ErrRemote require.ErrorAs(t, err, &denial) diff --git a/components/runner/runner.go b/components/runner/runner.go index 229a9b5c8..8b235fbdf 100644 --- a/components/runner/runner.go +++ b/components/runner/runner.go @@ -33,6 +33,7 @@ import ( "miren.dev/runtime/metrics" "miren.dev/runtime/network" "miren.dev/runtime/observability" + "miren.dev/runtime/pkg/certrevocation" "miren.dev/runtime/pkg/controller" "miren.dev/runtime/pkg/entity" "miren.dev/runtime/pkg/entity/types" @@ -175,9 +176,10 @@ func NewClusterAccess(log *slog.Logger, deps RunnerDeps, cfg RunnerConfig) (*Clu } return &ClusterAccess{ - RunnerConfig: cfg, - Log: log.With("module", "runner"), - deps: deps, + certRevocations: rpc.NewCertRevocations(), + RunnerConfig: cfg, + Log: log.With("module", "runner"), + deps: deps, }, nil } @@ -252,6 +254,12 @@ type ClusterAccess struct { entityBase *entityserver.Client sqliteDisks *sqlitedisk.Manager closers []io.Closer + + // certRevocations is enforced by this listener, which trusts the same CA as + // the coordinator's. It refuses certificates until the watch first syncs; + // nothing legitimate can reach a runner sooner, since its callers need the + // coordinator that sync depends on. + certRevocations *rpc.CertRevocations } // SandboxHost owns the node-local execution substrate. @@ -473,6 +481,7 @@ func (r *ClusterAccess) Start(ctx context.Context) (retErr error) { r.state = rs r.eac = es.NewEntityAccessClient(client) r.entityBase = entityserver.NewClient(r.Log, r.eac) + go certrevocation.Watch(ctx, r.Log, r.eac, r.certRevocations) if err := r.setupRemoteCoordinatorInfo(ctx, rs); err != nil { return fmt.Errorf("setting up coordinator registry and workload identity: %w", err) } @@ -497,6 +506,7 @@ func (r *ClusterAccess) newRPCState(ctx context.Context) (*rpc.State, error) { opts := []rpc.StateOption{ rpc.WithLogger(r.Log), rpc.WithBindAddr(r.ListenAddress), rpc.WithAuthenticator(&rpc.LocalOnlyAuthenticator{}), + rpc.WithCertRevocations(r.certRevocations), } if r.Config == nil { return nil, fmt.Errorf("runner cluster config is required to authenticate coordinator requests") @@ -558,7 +568,7 @@ func (r *SandboxHost) Start(ctx context.Context, eg ...*errgroup.Group) error { // Create exec server with explicit dependencies execServer := exec.NewServer(r.Log, r.deps.CC, r.access.eac, r.deps.Namespace, r.hubs) - r.access.state.Server().ExposeValue("dev.miren.runtime/exec", exec_v1alpha.AdaptSandboxExec(execServer)) + r.access.state.Server().ExposeValue("dev.miren.runtime/exec", exec_v1alpha.AdaptSandboxExec(coordinatorOnlyExec{execServer})) r.Log.Info("Registered exec server") From 6c9c1dbefab404238fe6f69f458ca10dbafe1528 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Thu, 5 Nov 2026 10:11:23 -0600 Subject: [PATCH 5/8] Refuse to revoke the coordinator's own API certificate The coordinator dials itself with its API certificate, so revoking that serial cut off the entity client behind every controller. The set only grows and the certificate is restored from cluster state on boot, so a restart failed at the loopback instead of recovering, leaving only a hand edit of etcd. The refusal names no fix because there is no way to rotate this certificate yet. --- components/coordinate/foundation.go | 6 +++++- servers/certrevocation/server.go | 12 ++++++++++-- servers/certrevocation/server_test.go | 20 ++++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/components/coordinate/foundation.go b/components/coordinate/foundation.go index 2cdcdf8c7..65f67e63b 100644 --- a/components/coordinate/foundation.go +++ b/components/coordinate/foundation.go @@ -778,8 +778,12 @@ func (c *Foundation) Start(ctx context.Context) (retErr error) { // server keeps enforcing what it has: a new revocation needs the same store, // and locking out operators and runners during an outage would gain nothing. go certrevocation.Watch(ctx, c.Log, eac, c.certRevocations) + apiCert, err := caauth.LoadCertificate(c.apiCert) + if err != nil { + return fmt.Errorf("reading API cert: %w", err) + } server.ExposeValue(rpc.ServiceCertRevocations, certrevocation_v1alpha.AdaptCertRevocations( - certrevocationsrv.NewServer(c.Log, ec, c.certRevocations), + certrevocationsrv.NewServer(c.Log, ec, c.certRevocations, rpc.CertSerial(apiCert.SerialNumber)), )) if err := c.prepareRunnerEtcdAccess(ctx, ec); err != nil { diff --git a/servers/certrevocation/server.go b/servers/certrevocation/server.go index 84facb181..1f5a45600 100644 --- a/servers/certrevocation/server.go +++ b/servers/certrevocation/server.go @@ -22,12 +22,17 @@ type Server struct { log *slog.Logger ec *aes.Client revocations *rpc.CertRevocations + apiSerial string } var _ certrevocation_v1alpha.CertRevocations = (*Server)(nil) -func NewServer(log *slog.Logger, ec *aes.Client, revocations *rpc.CertRevocations) *Server { - return &Server{log: log.With("module", "cert-revocations"), ec: ec, revocations: revocations} +// NewServer takes the serial of the coordinator's API certificate because the +// coordinator dials itself with that certificate, so revoking it would cut the +// coordinator off from its own entity store, and the set only grows, so a restart +// would not bring it back. +func NewServer(log *slog.Logger, ec *aes.Client, revocations *rpc.CertRevocations, apiSerial string) *Server { + return &Server{log: log.With("module", "cert-revocations"), ec: ec, revocations: revocations, apiSerial: apiSerial} } // allowOperator gates this service on identity, because the authorizer waves @@ -58,6 +63,9 @@ func (s *Server) Revoke(ctx context.Context, state *certrevocation_v1alpha.CertR if serial == rpc.CallerCertSerial(ctx) { return fmt.Errorf("refusing to revoke certificate %s: this command is running with it; reissue first, then revoke from the new certificate", serial) } + if serial == s.apiSerial { + return fmt.Errorf("refusing to revoke certificate %s: it is the coordinator's own API certificate, and the coordinator cannot run without it", serial) + } results := state.Results() diff --git a/servers/certrevocation/server_test.go b/servers/certrevocation/server_test.go index 665f3196c..d801f782c 100644 --- a/servers/certrevocation/server_test.go +++ b/servers/certrevocation/server_test.go @@ -2,6 +2,7 @@ package certrevocation import ( "context" + "strings" "testing" "github.com/stretchr/testify/require" @@ -10,7 +11,10 @@ import ( "miren.dev/runtime/pkg/rpc" ) -const leaked = "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64" +const ( + leaked = "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64" + apiSerial = "8d3e6a1f4c7b9e20d5f1a3c6b8e2470f" +) func newTestServer(t *testing.T) (*certrevocation_v1alpha.CertRevocationsClient, *rpc.CertRevocations) { t.Helper() @@ -19,7 +23,7 @@ func newTestServer(t *testing.T) (*certrevocation_v1alpha.CertRevocationsClient, set := rpc.NewCertRevocations() set.Load(nil) - server := NewServer(testutils.TestLogger(t), inmem.Client, set) + server := NewServer(testutils.TestLogger(t), inmem.Client, set, apiSerial) return certrevocation_v1alpha.NewCertRevocationsClient(rpc.LocalClient(certrevocation_v1alpha.AdaptCertRevocations(server))), set } @@ -73,6 +77,18 @@ func TestRevokeRefusesCallersOwnCertificate(t *testing.T) { require.NoError(t, err, "a different certificate is fine") } +func TestRevokeRefusesCoordinatorAPICertificate(t *testing.T) { + c, set := newTestServer(t) + + _, err := c.Revoke(operator(), strings.ToUpper(apiSerial), "") + require.ErrorContains(t, err, "coordinator's own API certificate") + require.False(t, set.Contains(apiSerial)) + + listed, err := c.List(operator()) + require.NoError(t, err) + require.Empty(t, listed.Revocations(), "nothing stored for a restart to load") +} + func TestOperatorsOnly(t *testing.T) { denied := []struct { name string From b83865d3a5b6188d8e8e0a6935ca8bca88f99d75 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Thu, 5 Nov 2026 10:11:30 -0600 Subject: [PATCH 6/8] rpc: note that revocation enforcement is HTTP only Message-session listeners dispatch without passing through ServeHTTP. Nothing enables them on a State that verifies certificates today, but whoever does needs to know revocation will not apply there. --- pkg/rpc/state.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/rpc/state.go b/pkg/rpc/state.go index 21e918d60..406833727 100644 --- a/pkg/rpc/state.go +++ b/pkg/rpc/state.go @@ -394,7 +394,8 @@ func WithHTTPHandler(pattern string, handler http.Handler) StateOption { // WithCertRevocations enforces a revocation set on every request that presents a // verified client certificate. Until the set is loaded, such requests are refused -// with a retryable 503. +// with a retryable 503. Enforcement lives in Server.ServeHTTP, so the listeners +// from WithTCPBindAddr and WithWSBindAddr are not covered. func WithCertRevocations(revocations *CertRevocations) StateOption { return func(o *stateOptions) { o.certRevocations = revocations From e2e74ef29c21f516e70b24bbcfc56aecc9cd2d8a Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Thu, 5 Nov 2026 14:22:34 -0600 Subject: [PATCH 7/8] runner: word coordinator refusals for every method they guard requireCoordinator started out guarding node administration, and its errors said so. It now also guards the query service and, since this PR, exec and attach, so someone refused on exec was told about node administration. The wording now fits every caller. --- components/runner/nodeadmin.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/runner/nodeadmin.go b/components/runner/nodeadmin.go index a8cdd5afc..e86dfcd46 100644 --- a/components/runner/nodeadmin.go +++ b/components/runner/nodeadmin.go @@ -116,13 +116,13 @@ func (e coordinatorOnlyExec) Attach(ctx context.Context, state *exec_v1alpha.San func requireCoordinator(ctx context.Context) error { identity := rpc.IdentityFromContext(ctx) if identity == nil || identity.Method == rpc.AuthMethodAnonymous { - return fmt.Errorf("node administration requires the coordinator's certificate, and this caller presented none") + return fmt.Errorf("this call requires the coordinator's certificate, and this caller presented none") } if identity.Method != rpc.AuthMethodCert { - return fmt.Errorf("node administration requires a certificate, got %q", identity.Method) + return fmt.Errorf("this call requires a certificate, got %q", identity.Method) } if identity.Subject != rpc.CoordinatorCertSubject { - return fmt.Errorf("only the coordinator may administer this node, not %q", identity.Subject) + return fmt.Errorf("only the coordinator may call this on a runner, not %q", identity.Subject) } return nil } From af4256173ddcf6fa08c628112b9ec70e824eb40d Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Thu, 5 Nov 2026 14:22:34 -0600 Subject: [PATCH 8/8] cli: put auth revoke's limits in its help The limits only lived in the PR description, and the generated auth-revoke page listed nothing but flags, which is a poor place to learn them in the middle of a leak. A runner certificate gets its own paragraph. RefreshCertificate lets it mint a replacement with a fresh serial, and the cluster keeps no record of what it issued, so revoking the old serial leaves the replacement alone. Removing the runner stops further renewals but does not touch a replacement already minted, so the help says to do both and to go after any serial the audit log turns up. --help does not render WithDescription for any command, so that text reaches only the docs page. The runner point also goes in the warning printed after a revoke, where someone mid-leak will actually see it. We can't tell from a serial whether it was a runner's, so it prints every time and says so. --- cli/commands/auth_revoke.go | 1 + cli/commands/commands.go | 11 +++++++++++ docs/docs/command/auth-revoke.md | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/cli/commands/auth_revoke.go b/cli/commands/auth_revoke.go index 49579c594..514d6afc7 100644 --- a/cli/commands/auth_revoke.go +++ b/cli/commands/auth_revoke.go @@ -116,6 +116,7 @@ func AuthRevoke(ctx *Context, opts struct { // The cluster has no record of what it issued, so success proves only that // this serial is now refused, not that it was the certificate you meant. fmt.Fprintln(ctx.Stderr, "Warning: the cluster keeps no record of issued certificates, so it cannot confirm this serial belongs to a real one. Check it against the certificate or audit log.") + fmt.Fprintln(ctx.Stderr, "Warning: a runner's certificate may already have renewed itself under a new serial. If this was one, also run `miren runner delete` and check the audit log for other serials with that runner's subject.") return nil } diff --git a/cli/commands/commands.go b/cli/commands/commands.go index 103c8ec57..2ae00dde2 100644 --- a/cli/commands/commands.go +++ b/cli/commands/commands.go @@ -1232,6 +1232,7 @@ miren deployment create --format jsonl | jq -c 'select(.event == "build_step")' }), )) d.Dispatch("auth revoke", Infer("auth revoke", "Revoke a client certificate so the cluster refuses it", AuthRevoke, + WithDescription(authRevokeDescription), WithExample(mflags.Example{ Name: "Revoke a leaked certificate", Body: "miren auth revoke 4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64 --reason \"leaked\"", @@ -1491,6 +1492,16 @@ const debugDeployEventsDescription = `A deploy run with ` + "`" + `--format json Build step log lines are hidden unless ` + "`" + `--build-logs` + "`" + ` is set; ` + "`" + `--timestamps` + "`" + ` prefixes every line with the time since the first event, which is the quickest way to see where a slow deploy spent its time. Lines that are not JSON (stderr text mixed into the same file, say) are shown as they are.` +const authRevokeDescription = `Serials can be given in either base. Digits alone are read as decimal, which is how audit logs print them. A letter, a colon, or a ` + "`0x`" + ` prefix means hex, which is how openssl prints them. The confirmation prompt shows both forms so you can catch a wrong guess. + +The cluster keeps no record of the certificates it issued, so it cannot tell a typo from a real serial. Check the serial against the certificate or the audit log first. + +A revocation cannot be undone from the CLI. To restore someone's access, issue them a new certificate with ` + "`miren auth generate`" + `. + +The command refuses to revoke the certificate it is running with, and the coordinator's own API certificate. + +A leaked runner certificate needs more than this command. It can renew itself into a new certificate with a new serial, and revoking the old serial does not touch the new one. Also run ` + "`miren runner delete`" + ` to stop further renewals, then check the audit log for other serials with that runner's subject and revoke those too.` + const rollbackDescription = `Rollback re-activates a previous version by reusing its already-resolved image. No image selection or build happens. It presents a picker of recent successful deployments and rolls out the one you choose immediately. The currently active version is excluded since rolling back to it would be a no-op. Rollback creates a new deployment record; it does not erase history.` diff --git a/docs/docs/command/auth-revoke.md b/docs/docs/command/auth-revoke.md index 8784611c8..397e6c92d 100644 --- a/docs/docs/command/auth-revoke.md +++ b/docs/docs/command/auth-revoke.md @@ -8,6 +8,16 @@ description: "Revoke a client certificate so the cluster refuses it" Revoke a client certificate so the cluster refuses it +Serials can be given in either base. Digits alone are read as decimal, which is how audit logs print them. A letter, a colon, or a `0x` prefix means hex, which is how openssl prints them. The confirmation prompt shows both forms so you can catch a wrong guess. + +The cluster keeps no record of the certificates it issued, so it cannot tell a typo from a real serial. Check the serial against the certificate or the audit log first. + +A revocation cannot be undone from the CLI. To restore someone's access, issue them a new certificate with `miren auth generate`. + +The command refuses to revoke the certificate it is running with, and the coordinator's own API certificate. + +A leaked runner certificate needs more than this command. It can renew itself into a new certificate with a new serial, and revoking the old serial does not touch the new one. Also run `miren runner delete` to stop further renewals, then check the audit log for other serials with that runner's subject and revoke those too. + ## Usage ```bash