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 '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.") + 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 +} + +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..2ae00dde2 100644 --- a/cli/commands/commands.go +++ b/cli/commands/commands.go @@ -1231,6 +1231,15 @@ 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, + WithDescription(authRevokeDescription), + 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)) @@ -1483,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/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/components/coordinate/foundation.go b/components/coordinate/foundation.go index 829bd97a3..65f67e63b 100644 --- a/components/coordinate/foundation.go +++ b/components/coordinate/foundation.go @@ -17,12 +17,14 @@ import ( "time" clientv3 "go.etcd.io/etcd/client/v3" + "miren.dev/runtime/api/certrevocation/certrevocation_v1alpha" "miren.dev/runtime/api/core/core_v1alpha" aes "miren.dev/runtime/api/entityserver" esv1 "miren.dev/runtime/api/entityserver/entityserver_v1alpha" "miren.dev/runtime/clientconfig" deploymentattemptsctrl "miren.dev/runtime/controllers/deploymentattempts" "miren.dev/runtime/pkg/caauth" + "miren.dev/runtime/pkg/certrevocation" "miren.dev/runtime/pkg/cloudauth" "miren.dev/runtime/pkg/clusternetwork" "miren.dev/runtime/pkg/clusterstate" @@ -32,6 +34,7 @@ import ( "miren.dev/runtime/pkg/oidcauth" "miren.dev/runtime/pkg/rpc" "miren.dev/runtime/pkg/workloadidentity" + certrevocationsrv "miren.dev/runtime/servers/certrevocation" "miren.dev/runtime/servers/entityserver" "miren.dev/runtime/servers/metricspush" "miren.dev/runtime/servers/runnertelemetry" @@ -65,6 +68,9 @@ type Foundation struct { authority *caauth.Authority + // certRevocations is enforced by the listener from the moment it opens. + certRevocations *rpc.CertRevocations + apiCert []byte apiKey []byte @@ -556,6 +562,8 @@ func (c *Foundation) Start(ctx context.Context) (retErr error) { return fmt.Errorf("initializing coordinator state: %w", err) } + c.certRevocations = rpc.NewCertRevocations() + // Prepare RPC options rpcOpts := []rpc.StateOption{ rpc.WithCertPEMs(c.apiCert, c.apiKey), @@ -566,6 +574,7 @@ func (c *Foundation) Start(ctx context.Context) (retErr error) { // on the port the cluster already advertises. rpc.WithRESTBindAddr(c.Address), rpc.WithLogger(c.Log), + rpc.WithCertRevocations(c.certRevocations), } // Add cloud authenticator if enabled @@ -694,6 +703,16 @@ func (c *Foundation) Start(ctx context.Context) (retErr error) { return err } + // The listener is already open and refuses certificates until this load + // finishes. It reads the store directly because the loopback client below + // would go through that same listener. Unlike the maintenance that follows + // this is not best-effort: starting without the set could readmit a + // revoked certificate. + if err := certrevocation.Load(ctx, c.Log, etcdStore, c.certRevocations); err != nil { + c.Log.Error("failed to load certificate revocations", "error", err) + return err + } + // Best-effort startup maintenance (format migration and short-id backfill) // scans the entity store. Bound it with a timeout so that a large or // not-recently-compacted store fails fast and lets startup continue, rather @@ -754,6 +773,19 @@ func (c *Foundation) Start(ctx context.Context) (retErr error) { } ec := aes.NewClient(c.Log, eac) + + // From here on the set only needs to stay current. If the watch is down the + // 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, rpc.CertSerial(apiCert.SerialNumber)), + )) + if err := c.prepareRunnerEtcdAccess(ctx, ec); err != nil { return fmt.Errorf("provisioning runner etcd access: %w", err) } diff --git a/components/runner/nodeadmin.go b/components/runner/nodeadmin.go index e43899ab2..e86dfcd46 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 @@ -92,13 +116,13 @@ func (s *nodeAdminServer) InstallDiskAccelerator(ctx context.Context, req *nodea 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 } 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") 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..397e6c92d --- /dev/null +++ b/docs/docs/command/auth-revoke.md @@ -0,0 +1,56 @@ +--- +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 + +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 +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/certrevocation/certrevocation.go b/pkg/certrevocation/certrevocation.go new file mode 100644 index 000000000..81c3b6107 --- /dev/null +++ b/pkg/certrevocation/certrevocation.go @@ -0,0 +1,135 @@ +// Package certrevocation keeps an rpc.CertRevocations in step with the +// certificate_revocation entities in the store. +package certrevocation + +import ( + "context" + "fmt" + "log/slog" + + "miren.dev/runtime/api/core/core_v1alpha" + "miren.dev/runtime/api/entityserver/entityserver_v1alpha" + "miren.dev/runtime/pkg/entity" + "miren.dev/runtime/pkg/entity/indexwatch" + "miren.dev/runtime/pkg/rpc" +) + +const loadPageSize = 500 + +var kindIndex = entity.Ref(entity.EntityKind, core_v1alpha.KindCertificateRevocation) + +// Load installs every stored revocation into set, leaving it loaded. Any error +// leaves it unloaded and should stop the server: a record that cannot be read +// might be the only thing keeping a revoked certificate out, so nothing short of +// every record counts as loaded. Clearing a bad record then means repairing it in +// etcd, since the API is not up to do it. +// +// Load runs before the entity server exists, which is why it takes the store +// rather than an RPC client. +func Load(ctx context.Context, log *slog.Logger, store entity.Store, set *rpc.CertRevocations) error { + var serials []string + cursor := "" + for { + page, err := store.ListIndexEntitiesPage(ctx, kindIndex, cursor, loadPageSize) + if err != nil { + return fmt.Errorf("listing certificate revocations: %w", err) + } + for i, e := range page.Entities { + if e == nil { + if page.Undecodable[page.Ids[i]] { + return fmt.Errorf("certificate revocation %s is unreadable", page.Ids[i]) + } + continue + } + serial, err := serialOf(e) + if err != nil { + return fmt.Errorf("certificate revocation %s: %w", e.Id(), err) + } + serials = append(serials, serial) + } + cursor = page.Cursor + if cursor == "" { + break + } + } + + set.Load(serials) + log.Info("certificate revocations loaded", "count", len(serials)) + return nil +} + +// Watch keeps set current until ctx ends. On the coordinator it picks up writes +// that bypassed the Revoke handler. On a runner it is the only source, so the +// runner refuses certificates until the first snapshot arrives, and a revocation +// reaches it a moment after Revoke returns. +func Watch(ctx context.Context, log *slog.Logger, eac *entityserver_v1alpha.EntityAccessClient, set *rpc.CertRevocations) { + w := indexwatch.New(eac, kindIndex, indexwatch.Options{Logger: log}) + if err := w.Start(ctx); err != nil { + log.Error("failed to watch certificate revocations", "error", err) + return + } + defer w.Stop() + + follow(ctx, log, w.Updates(), set) +} + +// follow applies store changes to set, and only ever adds. A snapshot can predate +// a revocation already enforced and listings omit records that will not decode, +// while a delete event can be older than a write-through Revoke of the same +// serial, so neither can safely lift anything. Deleting a revocation by hand +// takes effect when each process restarts. +func follow(ctx context.Context, log *slog.Logger, updates <-chan indexwatch.Event, set *rpc.CertRevocations) { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-updates: + if !ok { + return + } + switch ev.Type { + case indexwatch.EventSync: + serials := make([]string, 0, len(ev.Entities)) + complete := true + for _, e := range ev.Entities { + serial, err := serialOf(e) + if err != nil { + log.Error("unusable certificate revocation", "id", e.Id(), "error", err) + complete = false + continue + } + serials = append(serials, serial) + } + // A first snapshot with a bad record leaves a runner unloaded, the + // same outcome as the coordinator failing to boot on one. A set + // already loaded keeps enforcing rather than lock out a live node. + if !complete && !set.Loaded() { + for _, serial := range serials { + set.Add(serial) + } + continue + } + set.Load(serials) + log.Info("certificate revocations synced", "count", len(serials)) + case indexwatch.EventAdded, indexwatch.EventUpdated: + if ev.Entity == nil { + continue + } + serial, err := serialOf(ev.Entity) + if err != nil { + log.Warn("ignoring certificate revocation", "id", ev.Id, "error", err) + continue + } + set.Add(serial) + log.Info("certificate revocation applied", "serial", serial) + case indexwatch.EventDeleted: + } + } + } +} + +func serialOf(e *entity.Entity) (string, error) { + var revocation core_v1alpha.CertificateRevocation + revocation.Decode(e) + return rpc.ParseCertSerial(revocation.Serial) +} diff --git a/pkg/certrevocation/certrevocation_test.go b/pkg/certrevocation/certrevocation_test.go new file mode 100644 index 000000000..d8a1da2b9 --- /dev/null +++ b/pkg/certrevocation/certrevocation_test.go @@ -0,0 +1,161 @@ +package certrevocation + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + "miren.dev/runtime/api/core/core_v1alpha" + "miren.dev/runtime/pkg/entity" + "miren.dev/runtime/pkg/entity/indexwatch" + "miren.dev/runtime/pkg/entity/testutils" + "miren.dev/runtime/pkg/rpc" +) + +type failingStore struct { + entity.Store +} + +func (failingStore) ListIndexEntitiesPage(context.Context, entity.Attr, string, int64) (*entity.EntityPage, error) { + return nil, errors.New("etcd unavailable") +} + +type undecodableStore struct { + entity.Store +} + +func (undecodableStore) ListIndexEntitiesPage(context.Context, entity.Attr, string, int64) (*entity.EntityPage, error) { + return &entity.EntityPage{ + Ids: []entity.Id{"revocation/corrupt"}, + Entities: []*entity.Entity{nil}, + Undecodable: map[entity.Id]bool{"revocation/corrupt": true}, + }, nil +} + +func TestLoad(t *testing.T) { + inmem, cleanup := testutils.NewInMemEntityServer(t) + t.Cleanup(cleanup) + ctx := t.Context() + + _, err := inmem.Client.Create(ctx, "abc", &core_v1alpha.CertificateRevocation{Serial: "abc", RevokedAt: time.Now()}) + require.NoError(t, err) + + t.Run("installs stored revocations", func(t *testing.T) { + set := rpc.NewCertRevocations() + require.NoError(t, Load(ctx, testutils.TestLogger(t), inmem.Store, set)) + require.True(t, set.Loaded()) + require.True(t, set.Contains("abc")) + require.False(t, set.Contains("def")) + }) + + t.Run("a failed read leaves the set unloaded", func(t *testing.T) { + set := rpc.NewCertRevocations() + require.Error(t, Load(ctx, testutils.TestLogger(t), failingStore{inmem.Store}, set)) + require.False(t, set.Loaded(), "an unloaded set is what keeps certificates out") + }) + + t.Run("an unreadable record fails the load", func(t *testing.T) { + set := rpc.NewCertRevocations() + require.ErrorContains(t, Load(ctx, testutils.TestLogger(t), undecodableStore{inmem.Store}, set), "unreadable") + require.False(t, set.Loaded()) + }) + + t.Run("a record with no usable serial fails the load", func(t *testing.T) { + _, err := inmem.Client.Create(ctx, "bad", &core_v1alpha.CertificateRevocation{Serial: "not-hex"}) + require.NoError(t, err) + + set := rpc.NewCertRevocations() + require.ErrorContains(t, Load(ctx, testutils.TestLogger(t), inmem.Store, set), "invalid certificate serial") + require.False(t, set.Loaded()) + }) +} + +func runFollow(t *testing.T, set *rpc.CertRevocations, events ...indexwatch.Event) { + t.Helper() + updates := make(chan indexwatch.Event, len(events)) + for _, ev := range events { + updates <- ev + } + close(updates) + follow(t.Context(), testutils.TestLogger(t), updates, set) +} + +func revocationEntity(id entity.Id, serial string) *entity.Entity { + return entity.New(entity.Ident, id, (&core_v1alpha.CertificateRevocation{Serial: serial}).Encode) +} + +func TestFollow(t *testing.T) { + set := rpc.NewCertRevocations() + + a := revocationEntity("revocation/a", "a") + runFollow(t, set, + indexwatch.Event{Type: indexwatch.EventSync, Entities: []*entity.Entity{a}}, + indexwatch.Event{Type: indexwatch.EventAdded, Id: "revocation/b", Entity: revocationEntity("revocation/b", "b")}, + indexwatch.Event{Type: indexwatch.EventAdded, Id: "revocation/bad", Entity: revocationEntity("revocation/bad", "not-hex")}, + ) + + require.True(t, set.Loaded(), "a runner has no other way to load") + require.True(t, set.Contains("a")) + require.True(t, set.Contains("b")) +} + +func TestBadFirstSnapshotLeavesSetUnloaded(t *testing.T) { + bad := revocationEntity("revocation/bad", "not-hex") + good := revocationEntity("revocation/a", "a") + + t.Run("not yet loaded", func(t *testing.T) { + set := rpc.NewCertRevocations() + runFollow(t, set, indexwatch.Event{Type: indexwatch.EventSync, Entities: []*entity.Entity{good, bad}}) + require.False(t, set.Loaded(), "an incomplete list must not open the gate") + require.True(t, set.Contains("a")) + }) + + t.Run("already loaded", func(t *testing.T) { + set := rpc.NewCertRevocations() + set.Load(nil) + runFollow(t, set, indexwatch.Event{Type: indexwatch.EventSync, Entities: []*entity.Entity{good, bad}}) + require.True(t, set.Loaded()) + require.True(t, set.Contains("a")) + }) +} + +// A queued delete can be older than a write-through Revoke of the same serial. +func TestDeleteEventNeverLiftsARevocation(t *testing.T) { + set := rpc.NewCertRevocations() + set.Load(nil) + + a := revocationEntity("revocation/a", "a") + runFollow(t, set, + indexwatch.Event{Type: indexwatch.EventSync, Entities: []*entity.Entity{a}}, + indexwatch.Event{Type: indexwatch.EventDeleted, Id: a.Id(), Entity: a}, + ) + + require.True(t, set.Contains("a")) +} + +// A snapshot can predate a revocation the handler already enforced, and it omits +// records that will not decode, so leaving a serial out must not lift it. +func TestSnapshotNeverLiftsARevocation(t *testing.T) { + set := rpc.NewCertRevocations() + set.Load([]string{"abc"}) + set.Add("def") + + runFollow(t, set, indexwatch.Event{Type: indexwatch.EventSync}) + + require.True(t, set.Contains("abc")) + require.True(t, set.Contains("def")) +} + +// The server must outlive the store: losing the watch changes nothing about +// what it enforces. +func TestFollowEndingKeepsLastKnownSet(t *testing.T) { + set := rpc.NewCertRevocations() + set.Load([]string{"abc"}) + + runFollow(t, set) + + require.True(t, set.Loaded()) + require.True(t, set.Contains("abc")) +} 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..4d392ca62 --- /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("4F1A9C3E7B2D8A6F0C5E1D3B9A7F2E64") + require.NoError(t, err) + require.Equal(t, "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64", 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/service.go b/pkg/rpc/service.go index a399dca01..26138a881 100644 --- a/pkg/rpc/service.go +++ b/pkg/rpc/service.go @@ -20,4 +20,7 @@ const ( // ServiceSqliteBackup stores LTX transaction files replicated from // SQLite-provider disks on runners. ServiceSqliteBackup ServiceID = "dev.miren.runtime/sqlite-backup" + + // ServiceCertRevocations lets operators revoke client certificates. + ServiceCertRevocations ServiceID = "dev.miren.runtime/cert-revocations" ) diff --git a/pkg/rpc/state.go b/pkg/rpc/state.go index a8237e890..406833727 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,16 @@ 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. 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 + } +} + func WithBearerToken(token string) StateOption { return func(o *stateOptions) { o.bearerToken = token @@ -514,12 +531,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, diff --git a/servers/certrevocation/server.go b/servers/certrevocation/server.go new file mode 100644 index 000000000..1f5a45600 --- /dev/null +++ b/servers/certrevocation/server.go @@ -0,0 +1,142 @@ +// Package certrevocation serves client certificate revocation to operators. +package certrevocation + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "time" + + "miren.dev/runtime/api/certrevocation/certrevocation_v1alpha" + "miren.dev/runtime/api/core/core_v1alpha" + aes "miren.dev/runtime/api/entityserver" + "miren.dev/runtime/pkg/cond" + "miren.dev/runtime/pkg/entity" + "miren.dev/runtime/pkg/rpc" +) + +type Server struct { + log *slog.Logger + ec *aes.Client + revocations *rpc.CertRevocations + apiSerial string +} + +var _ certrevocation_v1alpha.CertRevocations = (*Server)(nil) + +// 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 +// every certificate through and runner certificates must not qualify. JWT callers +// are additionally subject to cloud RBAC at the transport. +func allowOperator(ctx context.Context) (*rpc.Identity, error) { + id := rpc.IdentityFromContext(ctx) + if id == nil || (id.Method != rpc.AuthMethodCert && id.Method != rpc.AuthMethodJWT) || rpc.BoundApp(ctx) != "" { + return nil, fmt.Errorf("%w: certificate revocation requires a cluster operator", rpc.ErrUnauthorized) + } + if id.Method == rpc.AuthMethodCert && (id.Subject == "miren-runner" || strings.HasPrefix(id.Subject, "runner-")) { + return nil, fmt.Errorf("%w: certificate revocation requires a cluster operator", rpc.ErrUnauthorized) + } + return id, nil +} + +func (s *Server) Revoke(ctx context.Context, state *certrevocation_v1alpha.CertRevocationsRevoke) error { + id, err := allowOperator(ctx) + if err != nil { + return err + } + + args := state.Args() + serial, err := rpc.ParseCertSerial(args.Serial()) + if err != nil { + return err + } + 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() + + var existing core_v1alpha.CertificateRevocation + err = s.ec.Get(ctx, serial, &existing) + switch { + case err == nil: + // The set may not have heard yet if the entity arrived some other way. + s.revocations.Add(serial) + results.SetRevocation(revocationInfo(&existing)) + results.SetAlreadyRevoked(true) + return nil + case !errors.Is(err, cond.ErrNotFound{}): + return fmt.Errorf("checking for an existing revocation: %w", err) + } + + revocation := &core_v1alpha.CertificateRevocation{ + Serial: serial, + RevokedAt: time.Now().UTC(), + RevokedBy: id.Subject, + Reason: args.Reason(), + } + if _, err := s.ec.Create(ctx, serial, revocation); err != nil { + return fmt.Errorf("storing revocation: %w", err) + } + + // Write through rather than wait for the watch, so the certificate is + // refused by the time this call returns. + s.revocations.Add(serial) + s.log.Info("client certificate revoked", "serial", serial, "revoked_by", id.Subject, "reason", args.Reason()) + + results.SetRevocation(revocationInfo(revocation)) + results.SetAlreadyRevoked(false) + return nil +} + +func (s *Server) List(ctx context.Context, state *certrevocation_v1alpha.CertRevocationsList) error { + if _, err := allowOperator(ctx); err != nil { + return err + } + + listed, err := s.ec.List(ctx, entity.Ref(entity.EntityKind, core_v1alpha.KindCertificateRevocation)) + if err != nil { + return fmt.Errorf("listing revocations: %w", err) + } + + var stored []*core_v1alpha.CertificateRevocation + for listed.Next() { + var revocation core_v1alpha.CertificateRevocation + if err := listed.Read(&revocation); err != nil { + return err + } + stored = append(stored, &revocation) + } + slices.SortFunc(stored, func(a, b *core_v1alpha.CertificateRevocation) int { + return a.RevokedAt.Compare(b.RevokedAt) + }) + + infos := make([]*certrevocation_v1alpha.Revocation, 0, len(stored)) + for _, revocation := range stored { + infos = append(infos, revocationInfo(revocation)) + } + state.Results().SetRevocations(infos) + return nil +} + +func revocationInfo(r *core_v1alpha.CertificateRevocation) *certrevocation_v1alpha.Revocation { + var info certrevocation_v1alpha.Revocation + info.SetSerial(r.Serial) + info.SetRevokedAt(r.RevokedAt.Format(time.RFC3339)) + info.SetRevokedBy(r.RevokedBy) + info.SetReason(r.Reason) + return &info +} diff --git a/servers/certrevocation/server_test.go b/servers/certrevocation/server_test.go new file mode 100644 index 000000000..d801f782c --- /dev/null +++ b/servers/certrevocation/server_test.go @@ -0,0 +1,128 @@ +package certrevocation + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "miren.dev/runtime/api/certrevocation/certrevocation_v1alpha" + "miren.dev/runtime/pkg/entity/testutils" + "miren.dev/runtime/pkg/rpc" +) + +const ( + leaked = "4f1a9c3e7b2d8a6f0c5e1d3b9a7f2e64" + apiSerial = "8d3e6a1f4c7b9e20d5f1a3c6b8e2470f" +) + +func newTestServer(t *testing.T) (*certrevocation_v1alpha.CertRevocationsClient, *rpc.CertRevocations) { + t.Helper() + inmem, cleanup := testutils.NewInMemEntityServer(t) + t.Cleanup(cleanup) + + set := rpc.NewCertRevocations() + set.Load(nil) + server := NewServer(testutils.TestLogger(t), inmem.Client, set, apiSerial) + return certrevocation_v1alpha.NewCertRevocationsClient(rpc.LocalClient(certrevocation_v1alpha.AdaptCertRevocations(server))), set +} + +func operator() context.Context { + return rpc.ContextWithIdentity(context.Background(), &rpc.Identity{Method: rpc.AuthMethodCert, Subject: "operator"}) +} + +func TestRevoke(t *testing.T) { + c, set := newTestServer(t) + ctx := operator() + + 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()) + require.Equal(t, "operator", first.Revocation().RevokedBy()) + require.True(t, set.Contains(leaked), "enforced by the time Revoke returns") + + again, err := c.Revoke(ctx, leaked, "a different reason") + require.NoError(t, err) + require.True(t, again.AlreadyRevoked()) + require.Equal(t, "leaked into a transcript", again.Revocation().Reason(), "the original record stands") + + listed, err := c.List(ctx) + require.NoError(t, err) + require.Len(t, listed.Revocations(), 1) + require.Equal(t, leaked, listed.Revocations()[0].Serial()) +} + +func TestRevokeRejectsMalformedSerial(t *testing.T) { + c, set := newTestServer(t) + + _, err := c.Revoke(operator(), "not-a-serial", "") + require.ErrorContains(t, err, "invalid certificate serial") + + listed, err := c.List(operator()) + require.NoError(t, err) + require.Empty(t, listed.Revocations()) + require.False(t, set.Contains("not-a-serial")) +} + +func TestRevokeRefusesCallersOwnCertificate(t *testing.T) { + c, set := newTestServer(t) + ctx := rpc.ContextWithCallerCertSerial(operator(), leaked) + + _, err := c.Revoke(ctx, leaked, "") + require.ErrorContains(t, err, "refusing to revoke") + require.False(t, set.Contains(leaked)) + + _, err = c.Revoke(ctx, "abc", "") + 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 + identity *rpc.Identity + }{ + {"no identity", nil}, + {"runner certificate", &rpc.Identity{Method: rpc.AuthMethodCert, Subject: "runner-abc"}}, + {"shared runner certificate", &rpc.Identity{Method: rpc.AuthMethodCert, Subject: "miren-runner"}}, + {"workload", &rpc.Identity{Method: rpc.AuthMethodWorkload, Subject: "w", Metadata: map[string]any{"app": "a"}}}, + {"oidc", &rpc.Identity{Method: rpc.AuthMethodOIDC, Subject: "ci", Metadata: map[string]any{"bound_app": "a"}}}, + {"system", &rpc.Identity{Method: rpc.AuthMethodSystem, Subject: "s"}}, + } + for _, tt := range denied { + t.Run(tt.name, func(t *testing.T) { + c, set := newTestServer(t) + ctx := context.Background() + if tt.identity != nil { + ctx = rpc.ContextWithIdentity(ctx, tt.identity) + } + + _, err := c.Revoke(ctx, leaked, "") + require.ErrorContains(t, err, "requires a cluster operator") + require.False(t, set.Contains(leaked)) + + _, err = c.List(ctx) + require.ErrorContains(t, err, "requires a cluster operator") + }) + } + + t.Run("cloud operator", func(t *testing.T) { + c, set := newTestServer(t) + ctx := rpc.ContextWithIdentity(context.Background(), &rpc.Identity{Method: rpc.AuthMethodJWT, Subject: "person"}) + _, err := c.Revoke(ctx, leaked, "") + require.NoError(t, err) + require.True(t, set.Contains(leaked)) + }) +}