diff --git a/gen/preset-roles.json b/gen/preset-roles.json index 5a33b2d8c2f..6a283669000 100755 --- a/gen/preset-roles.json +++ b/gen/preset-roles.json @@ -1 +1 @@ -{"access":{"kind":"role","version":"v7","metadata":{"name":"access","description":"Access cluster resources","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":true},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"logins":["{{internal.logins}}"],"node_labels":{"*":"*"},"rules":[{"resources":["event"],"verbs":["list","read"]},{"resources":["session"],"verbs":["read","list"],"where":"contains(session.participants, user.metadata.name)"},{"resources":["instance"],"verbs":["list","read"]},{"resources":["cluster_maintenance_config"],"verbs":["list","read"]}],"kubernetes_groups":["{{internal.kubernetes_groups}}"],"kubernetes_users":["{{internal.kubernetes_users}}"],"app_labels":{"*":"*"},"kubernetes_labels":{"*":"*"},"db_labels":{"*":"*"},"db_names":["{{internal.db_names}}"],"db_users":["{{internal.db_users}}"],"aws_role_arns":["{{internal.aws_role_arns}}"],"windows_desktop_logins":["{{internal.windows_logins}}"],"windows_desktop_labels":{"*":"*"},"azure_identities":["{{internal.azure_identities}}"],"kubernetes_resources":[{"kind":"*","namespace":"*","name":"*","verbs":["*"]}],"gcp_service_accounts":["{{internal.gcp_service_accounts}}"],"db_service_labels":{"*":"*"},"db_roles":["{{internal.db_roles}}"],"github_permissions":[{"orgs":["{{internal.github_orgs}}"]}]},"deny":{}}},"auditor":{"kind":"role","version":"v7","metadata":{"name":"auditor","description":"Review cluster events and replay sessions","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":false,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false},"allow":{"rules":[{"resources":["session"],"verbs":["list","read"]},{"resources":["event"],"verbs":["list","read"]},{"resources":["session_tracker"],"verbs":["list","read"]},{"resources":["cluster_alert"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["security_report"],"verbs":["list","read","use"]},{"resources":["audit_query"],"verbs":["list","read","use"]},{"resources":["bot_instance"],"verbs":["list","read"]},{"resources":["notification"],"verbs":["list","read"]}]},"deny":{}}},"editor":{"kind":"role","version":"v7","metadata":{"name":"editor","description":"Edit cluster configuration","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"rules":[{"resources":["user"],"verbs":["list","create","read","update","delete"]},{"resources":["role"],"verbs":["list","create","read","update","delete"]},{"resources":["bot"],"verbs":["list","create","read","update","delete"]},{"resources":["crown_jewel"],"verbs":["list","create","read","update","delete"]},{"resources":["db_object_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc"],"verbs":["list","create","read","update","delete"]},{"resources":["saml"],"verbs":["list","create","read","update","delete"]},{"resources":["github"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc_request"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_request"],"verbs":["list","create","read","update","delete"]},{"resources":["github_request"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_audit_config"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_auth_preference"],"verbs":["list","create","read","update","delete"]},{"resources":["auth_connector"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_name"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_networking_config"],"verbs":["list","create","read","update","delete"]},{"resources":["session_recording_config"],"verbs":["list","create","read","update","delete"]},{"resources":["external_audit_storage"],"verbs":["list","create","read","update","delete"]},{"resources":["ui_config"],"verbs":["list","create","read","update","delete"]},{"resources":["trusted_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["remote_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["token"],"verbs":["list","create","read","update","delete"]},{"resources":["connection_diagnostic"],"verbs":["list","create","read","update","delete"]},{"resources":["db"],"verbs":["list","create","read","update","delete"]},{"resources":["database_certificate"],"verbs":["list","create","read","update","delete"]},{"resources":["installer"],"verbs":["list","create","read","update","delete"]},{"resources":["device"],"verbs":["list","create","read","update","delete","create_enroll_token","enroll"]},{"resources":["db_service"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["login_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_idp_service_provider"],"verbs":["list","create","read","update","delete"]},{"resources":["user_group"],"verbs":["list","create","read","update","delete"]},{"resources":["plugin"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_assignment"],"verbs":["list","create","read","update","delete"]},{"resources":["lock"],"verbs":["list","create","read","update","delete"]},{"resources":["integration"],"verbs":["list","create","read","update","delete","use"]},{"resources":["billing"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_alert"],"verbs":["list","create","read","update","delete"]},{"resources":["access_list"],"verbs":["list","create","read","update","delete"]},{"resources":["node"],"verbs":["list","create","read","update","delete"]},{"resources":["discovery_config"],"verbs":["list","create","read","update","delete"]},{"resources":["security_report"],"verbs":["list","create","read","update","delete","use"]},{"resources":["audit_query"],"verbs":["list","create","read","update","delete","use"]},{"resources":["access_graph"],"verbs":["list","create","read","update","delete"]},{"resources":["server_info"],"verbs":["list","create","read","update","delete"]},{"resources":["access_monitoring_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["app_server"],"verbs":["list","create","read","update","delete"]},{"resources":["vnet_config"],"verbs":["list","create","read","update","delete"]},{"resources":["bot_instance"],"verbs":["list","create","read","update","delete"]},{"resources":["access_graph_settings"],"verbs":["list","create","read","update","delete"]},{"resources":["spiffe_federation"],"verbs":["list","create","read","update","delete"]},{"resources":["notification"],"verbs":["list","create","read","update","delete"]},{"resources":["static_host_user"],"verbs":["list","create","read","update","delete"]},{"resources":["user_task"],"verbs":["list","create","read","update","delete"]},{"resources":["aws_identity_center"],"verbs":["list","create","read","update","delete"]},{"resources":["contact"],"verbs":["list","create","read","update","delete"]},{"resources":["workload_identity"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_version"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_config"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_agent_rollout"],"verbs":["list","read"]},{"resources":["git_server"],"verbs":["list","create","read","update","delete"]}]},"deny":{}}}} \ No newline at end of file +{"access":{"kind":"role","version":"v7","metadata":{"name":"access","description":"Access cluster resources","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":true},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"logins":["{{internal.logins}}"],"node_labels":{"*":"*"},"rules":[{"resources":["event"],"verbs":["list","read"]},{"resources":["session"],"verbs":["read","list"],"where":"contains(session.participants, user.metadata.name)"},{"resources":["instance"],"verbs":["list","read"]},{"resources":["cluster_maintenance_config"],"verbs":["list","read"]}],"kubernetes_groups":["{{internal.kubernetes_groups}}"],"kubernetes_users":["{{internal.kubernetes_users}}"],"app_labels":{"*":"*"},"kubernetes_labels":{"*":"*"},"db_labels":{"*":"*"},"db_names":["{{internal.db_names}}"],"db_users":["{{internal.db_users}}"],"aws_role_arns":["{{internal.aws_role_arns}}"],"windows_desktop_logins":["{{internal.windows_logins}}"],"windows_desktop_labels":{"*":"*"},"azure_identities":["{{internal.azure_identities}}"],"kubernetes_resources":[{"kind":"*","namespace":"*","name":"*","verbs":["*"]}],"gcp_service_accounts":["{{internal.gcp_service_accounts}}"],"db_service_labels":{"*":"*"},"db_roles":["{{internal.db_roles}}"],"github_permissions":[{"orgs":["{{internal.github_orgs}}"]}]},"deny":{}}},"auditor":{"kind":"role","version":"v7","metadata":{"name":"auditor","description":"Review cluster events and replay sessions","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":false,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false},"allow":{"rules":[{"resources":["session"],"verbs":["list","read"]},{"resources":["event"],"verbs":["list","read"]},{"resources":["session_tracker"],"verbs":["list","read"]},{"resources":["cluster_alert"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["security_report"],"verbs":["list","read","use"]},{"resources":["audit_query"],"verbs":["list","read","use"]},{"resources":["bot_instance"],"verbs":["list","read"]},{"resources":["notification"],"verbs":["list","read"]}]},"deny":{}}},"editor":{"kind":"role","version":"v7","metadata":{"name":"editor","description":"Edit cluster configuration","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"rules":[{"resources":["user"],"verbs":["list","create","read","update","delete"]},{"resources":["role"],"verbs":["list","create","read","update","delete"]},{"resources":["bot"],"verbs":["list","create","read","update","delete"]},{"resources":["crown_jewel"],"verbs":["list","create","read","update","delete"]},{"resources":["db_object_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc"],"verbs":["list","create","read","update","delete"]},{"resources":["saml"],"verbs":["list","create","read","update","delete"]},{"resources":["github"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc_request"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_request"],"verbs":["list","create","read","update","delete"]},{"resources":["github_request"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_audit_config"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_auth_preference"],"verbs":["list","create","read","update","delete"]},{"resources":["auth_connector"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_name"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_networking_config"],"verbs":["list","create","read","update","delete"]},{"resources":["session_recording_config"],"verbs":["list","create","read","update","delete"]},{"resources":["external_audit_storage"],"verbs":["list","create","read","update","delete"]},{"resources":["ui_config"],"verbs":["list","create","read","update","delete"]},{"resources":["trusted_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["remote_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["token"],"verbs":["list","create","read","update","delete"]},{"resources":["connection_diagnostic"],"verbs":["list","create","read","update","delete"]},{"resources":["db"],"verbs":["list","create","read","update","delete"]},{"resources":["database_certificate"],"verbs":["list","create","read","update","delete"]},{"resources":["installer"],"verbs":["list","create","read","update","delete"]},{"resources":["device"],"verbs":["list","create","read","update","delete","create_enroll_token","enroll"]},{"resources":["db_service"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["login_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_idp_service_provider"],"verbs":["list","create","read","update","delete"]},{"resources":["user_group"],"verbs":["list","create","read","update","delete"]},{"resources":["plugin"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_assignment"],"verbs":["list","create","read","update","delete"]},{"resources":["lock"],"verbs":["list","create","read","update","delete"]},{"resources":["integration"],"verbs":["list","create","read","update","delete","use"]},{"resources":["billing"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_alert"],"verbs":["list","create","read","update","delete"]},{"resources":["access_list"],"verbs":["list","create","read","update","delete"]},{"resources":["node"],"verbs":["list","create","read","update","delete"]},{"resources":["discovery_config"],"verbs":["list","create","read","update","delete"]},{"resources":["security_report"],"verbs":["list","create","read","update","delete","use"]},{"resources":["audit_query"],"verbs":["list","create","read","update","delete","use"]},{"resources":["access_graph"],"verbs":["list","create","read","update","delete"]},{"resources":["server_info"],"verbs":["list","create","read","update","delete"]},{"resources":["access_monitoring_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["app_server"],"verbs":["list","create","read","update","delete"]},{"resources":["vnet_config"],"verbs":["list","create","read","update","delete"]},{"resources":["bot_instance"],"verbs":["list","create","read","update","delete"]},{"resources":["access_graph_settings"],"verbs":["list","create","read","update","delete"]},{"resources":["spiffe_federation"],"verbs":["list","create","read","update","delete"]},{"resources":["notification"],"verbs":["list","create","read","update","delete"]},{"resources":["static_host_user"],"verbs":["list","create","read","update","delete"]},{"resources":["user_task"],"verbs":["list","create","read","update","delete"]},{"resources":["aws_identity_center"],"verbs":["list","create","read","update","delete"]},{"resources":["contact"],"verbs":["list","create","read","update","delete"]},{"resources":["workload_identity"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_version"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_config"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_agent_rollout"],"verbs":["list","read"]},{"resources":["git_server"],"verbs":["list","create","read","update","delete"]},{"resources":["workload_identity_x509_revocation"],"verbs":["list","create","read","update","delete"]}]},"deny":{}}}} \ No newline at end of file diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 5355a3265a9..b761765240a 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -5290,6 +5290,17 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { } workloadidentityv1pb.RegisterWorkloadIdentityIssuanceServiceServer(server, workloadIdentityIssuanceService) + workloadIdentityRevocationService, err := workloadidentityv1.NewRevocationService(&workloadidentityv1.RevocationServiceConfig{ + Authorizer: cfg.Authorizer, + Emitter: cfg.Emitter, + Clock: cfg.AuthServer.GetClock(), + Store: cfg.AuthServer.Services.WorkloadIdentityX509Revocations, + }) + if err != nil { + return nil, trace.Wrap(err, "creating workload identity issuance service") + } + workloadidentityv1pb.RegisterWorkloadIdentityRevocationServiceServer(server, workloadIdentityRevocationService) + dbObjectImportRuleService, err := dbobjectimportrulev1.NewDatabaseObjectImportRuleService(dbobjectimportrulev1.DatabaseObjectImportRuleServiceConfig{ Authorizer: cfg.Authorizer, Backend: cfg.AuthServer.Services, diff --git a/lib/auth/machineid/workloadidentityv1/revocation_service.go b/lib/auth/machineid/workloadidentityv1/revocation_service.go new file mode 100644 index 00000000000..656ad53efba --- /dev/null +++ b/lib/auth/machineid/workloadidentityv1/revocation_service.go @@ -0,0 +1,325 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package workloadidentityv1 + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/gravitational/teleport" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/events" +) + +type workloadIdentityX509RevocationReadWriter interface { + GetWorkloadIdentityX509Revocation(ctx context.Context, name string) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) + ListWorkloadIdentityX509Revocations(ctx context.Context, pageSize int, token string) ([]*workloadidentityv1pb.WorkloadIdentityX509Revocation, string, error) + CreateWorkloadIdentityX509Revocation(ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentityX509Revocation) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) + UpdateWorkloadIdentityX509Revocation(ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentityX509Revocation) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) + DeleteWorkloadIdentityX509Revocation(ctx context.Context, name string) error + UpsertWorkloadIdentityX509Revocation(ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentityX509Revocation) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) +} + +// RevocationServiceConfig holds configuration options for the RevocationService. +type RevocationServiceConfig struct { + Authorizer authz.Authorizer + Store workloadIdentityX509RevocationReadWriter + Clock clockwork.Clock + Emitter apievents.Emitter + Logger *slog.Logger +} + +// RevocationService is the gRPC service for managing workload identity +// revocations. +// It implements the workloadidentityv1pb.WorkloadIdentityRevocationServiceServer +type RevocationService struct { + workloadidentityv1pb.UnimplementedWorkloadIdentityRevocationServiceServer + + authorizer authz.Authorizer + store workloadIdentityX509RevocationReadWriter + clock clockwork.Clock + emitter apievents.Emitter + logger *slog.Logger +} + +// NewRevocationService returns a new instance of the RevocationService. +func NewRevocationService(cfg *RevocationServiceConfig) (*RevocationService, error) { + switch { + case cfg.Store == nil: + return nil, trace.BadParameter("store service is required") + case cfg.Authorizer == nil: + return nil, trace.BadParameter("authorizer is required") + case cfg.Emitter == nil: + return nil, trace.BadParameter("emitter is required") + } + + if cfg.Logger == nil { + cfg.Logger = slog.With(teleport.ComponentKey, "workload_identity_revocation.service") + } + if cfg.Clock == nil { + cfg.Clock = clockwork.NewRealClock() + } + return &RevocationService{ + authorizer: cfg.Authorizer, + store: cfg.Store, + clock: cfg.Clock, + emitter: cfg.Emitter, + logger: cfg.Logger, + }, nil +} + +// GetWorkloadIdentityX509Revocation returns a WorkloadIdentityX509Revocation +// by name. An error is returned if the resource does not exist. +// Implements teleport.workloadidentity.v1.RevocationService/GetWorkloadIdentityX509Revocation +func (s *RevocationService) GetWorkloadIdentityX509Revocation( + ctx context.Context, req *workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest, +) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentityX509Revocation, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + + if req.Name == "" { + return nil, trace.BadParameter("name: must be non-empty") + } + + resource, err := s.store.GetWorkloadIdentityX509Revocation(ctx, req.Name) + if err != nil { + return nil, trace.Wrap(err) + } + + return resource, nil +} + +// ListWorkloadIdentityX509Revocations returns a list of +// WorkloadIdentityX509Revocation resources. It follows the Google API design +// guidelines for list pagination. +// Implements teleport.workloadidentity.v1.RevocationService/ListWorkloadIdentityX509Revocations +func (s *RevocationService) ListWorkloadIdentityX509Revocations( + ctx context.Context, req *workloadidentityv1pb.ListWorkloadIdentityX509RevocationsRequest, +) (*workloadidentityv1pb.ListWorkloadIdentityX509RevocationsResponse, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentityX509Revocation, types.VerbRead, types.VerbList); err != nil { + return nil, trace.Wrap(err) + } + + resources, nextToken, err := s.store.ListWorkloadIdentityX509Revocations( + ctx, + int(req.PageSize), + req.PageToken, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + return &workloadidentityv1pb.ListWorkloadIdentityX509RevocationsResponse{ + WorkloadIdentityX509Revocations: resources, + NextPageToken: nextToken, + }, nil +} + +// DeleteWorkloadIdentityX509Revocation deletes a WorkloadIdentityX509Revocation +// by name. An error is returned if the resource does not exist. +// Implements teleport.workloadidentity.v1.RevocationService/DeleteWorkloadIdentityX509Revocation +func (s *RevocationService) DeleteWorkloadIdentityX509Revocation( + ctx context.Context, req *workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest, +) (*emptypb.Empty, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentityX509Revocation, types.VerbDelete); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + if req.Name == "" { + return nil, trace.BadParameter("name: must be non-empty") + } + + if err := s.store.DeleteWorkloadIdentityX509Revocation(ctx, req.Name); err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityX509RevocationDelete{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityX509RevocationDeleteCode, + Type: events.WorkloadIdentityX509RevocationDeleteEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.Name, + }, + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for UpsertWorkloadIdentityX509Revocation", + "error", err, + ) + } + + return &emptypb.Empty{}, nil +} + +// CreateWorkloadIdentityX509Revocation creates a new WorkloadIdentityX509Revocation. +// Implements teleport.workloadidentity.v1.RevocationService/CreateWorkloadIdentityX509Revocation +func (s *RevocationService) CreateWorkloadIdentityX509Revocation( + ctx context.Context, req *workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest, +) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentityX509Revocation, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.store.CreateWorkloadIdentityX509Revocation(ctx, req.WorkloadIdentityX509Revocation) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityX509RevocationCreate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityX509RevocationCreateCode, + Type: events.WorkloadIdentityX509RevocationCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: created.GetMetadata().GetName(), + }, + Reason: created.GetSpec().GetReason(), + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for CreateWorkloadIdentityX509Revocation", + "error", err, + ) + } + + return created, nil +} + +// UpdateWorkloadIdentityX509Revocation updates an existing +// WorkloadIdentityX509Revocation. +// Implements teleport.workloadidentity.v1.RevocationService/UpdateWorkloadIdentityX509Revocation +func (s *RevocationService) UpdateWorkloadIdentityX509Revocation( + ctx context.Context, req *workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest, +) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentityX509Revocation, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.store.UpdateWorkloadIdentityX509Revocation(ctx, req.WorkloadIdentityX509Revocation) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityX509RevocationUpdate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityX509RevocationUpdateCode, + Type: events.WorkloadIdentityX509RevocationUpdateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: created.GetMetadata().GetName(), + }, + Reason: created.GetSpec().GetReason(), + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for UpdateWorkloadIdentityX509Revocation", + "error", err, + ) + } + + return created, nil +} + +// UpsertWorkloadIdentityX509Revocation updates or creates an existing +// WorkloadIdentityX509Revocation. +// Implements teleport.workloadidentity.v1.RevocationService/UpsertWorkloadIdentityX509Revocation +func (s *RevocationService) UpsertWorkloadIdentityX509Revocation( + ctx context.Context, req *workloadidentityv1pb.UpsertWorkloadIdentityX509RevocationRequest, +) (*workloadidentityv1pb.WorkloadIdentityX509Revocation, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind( + types.KindWorkloadIdentityX509Revocation, types.VerbCreate, types.VerbUpdate, + ); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.store.UpsertWorkloadIdentityX509Revocation(ctx, req.WorkloadIdentityX509Revocation) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityX509RevocationCreate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityX509RevocationCreateCode, + Type: events.WorkloadIdentityX509RevocationCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: created.GetMetadata().GetName(), + }, + Reason: created.GetSpec().GetReason(), + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for UpsertWorkloadIdentityX509Revocation", + "error", err, + ) + } + + return created, nil +} diff --git a/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go b/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go index 280425f71c1..9ee77426790 100644 --- a/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go +++ b/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go @@ -40,6 +40,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" apiproto "github.com/gravitational/teleport/api/client/proto" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" @@ -2084,3 +2085,907 @@ func TestResourceService_UpsertWorkloadIdentity(t *testing.T) { }) } } + +func TestRevocationService_CreateWorkloadIdentityX509Revocation(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbCreate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity revocation + preExisting, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityX509RevocationCreate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aa", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityX509RevocationCreate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityX509RevocationCreateCode, + Type: libevents.WorkloadIdentityX509RevocationCreateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: "aa", + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + Reason: "compromised", + }, + }, + { + name: "pre-existing", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: preExisting, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAlreadyExists(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "bb", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "spec.reason: is required") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "cc", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + tt.client.GetConnection(), + ) + res, err := client.CreateWorkloadIdentityX509Revocation(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentityX509Revocation, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentityX509Revocation(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityX509RevocationCreate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityX509RevocationCreate{}, "ConnectionMetadata"), + )) + } + }) + } +} + +func TestRevocationService_DeleteWorkloadIdentityX509Revocation(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbDelete}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity revocation + preExisting, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest + requireError require.ErrorAssertionFunc + checkNonExisting bool + requireEvent *events.WorkloadIdentityX509RevocationDelete + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest{ + Name: preExisting.GetMetadata().GetName(), + }, + requireError: require.NoError, + checkNonExisting: true, + requireEvent: &events.WorkloadIdentityX509RevocationDelete{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityX509RevocationDeleteCode, + Type: libevents.WorkloadIdentityX509RevocationDeleteEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: preExisting.GetMetadata().GetName(), + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + }, + }, + { + name: "non-existing", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest{ + Name: "i-do-not-exist", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsNotFound(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest{ + Name: "", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "name: must be non-empty") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityX509RevocationRequest{ + Name: "unauthorized", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + tt.client.GetConnection(), + ) + _, err := client.DeleteWorkloadIdentityX509Revocation(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkNonExisting { + _, err := srv.Auth().GetWorkloadIdentityX509Revocation(ctx, tt.req.Name) + require.True(t, trace.IsNotFound(err)) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityX509RevocationDelete) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + tt.requireEvent, + evt, + cmpopts.IgnoreFields(events.WorkloadIdentityX509RevocationDelete{}, "ConnectionMetadata"), + )) + } + }) + } +} + +func TestRevocationService_GetWorkloadIdentityX509Revocation(t *testing.T) { + t.Parallel() + srv, _ := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbRead}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity revocation + preExisting, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest + wantRes *workloadidentityv1pb.WorkloadIdentityX509Revocation + requireError require.ErrorAssertionFunc + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest{ + Name: preExisting.GetMetadata().GetName(), + }, + wantRes: preExisting, + requireError: require.NoError, + }, + { + name: "non-existing", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest{ + Name: "i-do-not-exist", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsNotFound(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest{ + Name: "", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "name: must be non-empty") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityX509RevocationRequest{ + Name: "unauthorized", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + tt.client.GetConnection(), + ) + got, err := client.GetWorkloadIdentityX509Revocation(ctx, tt.req) + tt.requireError(t, err) + + if tt.wantRes != nil { + require.Empty( + t, + cmp.Diff( + tt.wantRes, + got, + protocmp.Transform(), + ), + ) + } + }) + } +} + +func TestRevocationService_ListWorkloadIdentityX509Revocations(t *testing.T) { + t.Parallel() + srv, _ := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbRead, types.VerbList}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identitie revocations + // Two complete pages of ten, plus one incomplete page of nine + created := []*workloadidentityv1pb.WorkloadIdentityX509Revocation{} + for i := 0; i < 29; i++ { + r, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: fmt.Sprintf("%d%d", i, i), + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + created = append(created, r) + } + + t.Run("unauthorized", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + unauthorizedClient.GetConnection(), + ) + + _, err := client.ListWorkloadIdentityX509Revocations( + ctx, + &workloadidentityv1pb.ListWorkloadIdentityX509RevocationsRequest{}, + ) + require.True(t, trace.IsAccessDenied(err)) + }) + + t.Run("success - default page", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + authorizedClient.GetConnection(), + ) + + // For the default page size, we expect to get all results in one page + res, err := client.ListWorkloadIdentityX509Revocations(ctx, &workloadidentityv1pb.ListWorkloadIdentityX509RevocationsRequest{}) + require.NoError(t, err) + require.Len(t, res.WorkloadIdentityX509Revocations, 29) + require.Empty(t, res.NextPageToken) + for _, created := range created { + slices.ContainsFunc(res.WorkloadIdentityX509Revocations, func(resource *workloadidentityv1pb.WorkloadIdentityX509Revocation) bool { + return proto.Equal(created, resource) + }) + } + }) + + t.Run("success - page size 10", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + authorizedClient.GetConnection(), + ) + + fetched := []*workloadidentityv1pb.WorkloadIdentityX509Revocation{} + token := "" + iterations := 0 + for { + iterations++ + res, err := client.ListWorkloadIdentityX509Revocations(ctx, &workloadidentityv1pb.ListWorkloadIdentityX509RevocationsRequest{ + PageSize: 10, + PageToken: token, + }) + require.NoError(t, err) + fetched = append(fetched, res.WorkloadIdentityX509Revocations...) + if res.NextPageToken == "" { + break + } + token = res.NextPageToken + } + + require.Len(t, fetched, 29) + require.Equal(t, 3, iterations) + for _, created := range created { + slices.ContainsFunc(fetched, func(resource *workloadidentityv1pb.WorkloadIdentityX509Revocation) bool { + return proto.Equal(created, resource) + }) + } + }) +} + +func TestRevocationService_UpdateWorkloadIdentityX509Revocation(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbUpdate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity revocation + preExisting, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + // Create a pre-existing workload identity revocation + preExisting2, err := srv.Auth().CreateWorkloadIdentityX509Revocation( + ctx, + &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccee", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityX509RevocationUpdate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: preExisting, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityX509RevocationUpdate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityX509RevocationUpdateCode, + Type: libevents.WorkloadIdentityX509RevocationUpdateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: preExisting.GetMetadata().GetName(), + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + Reason: "compromised", + }, + }, + { + name: "incorrect revision", + client: authorizedClient, + req: (func() *workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest { + preExisting2.Metadata.Revision = "incorrect" + return &workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: preExisting2, + } + })(), + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsCompareFailed(err)) + }, + }, + { + name: "not existing", + client: authorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd404", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: preExisting, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + tt.client.GetConnection(), + ) + res, err := client.UpdateWorkloadIdentityX509Revocation(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + require.NotEqual(t, tt.req.WorkloadIdentityX509Revocation.GetMetadata().GetRevision(), res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentityX509Revocation, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentityX509Revocation(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityX509RevocationUpdate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityX509RevocationUpdate{}, "ConnectionMetadata"), + )) + } + }) + } +} + +func TestRevocationService_UpsertWorkloadIdentityX509Revocation(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentityX509Revocation}, + Verbs: []string{types.VerbCreate, types.VerbUpdate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.UpsertWorkloadIdentityX509RevocationRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityX509RevocationCreate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityX509RevocationCreate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityX509RevocationCreateCode, + Type: libevents.WorkloadIdentityX509RevocationCreateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: "aabbccdd", + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + Reason: "compromised", + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "spec.reason: is required") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityX509RevocationRequest{ + WorkloadIdentityX509Revocation: &workloadidentityv1pb.WorkloadIdentityX509Revocation{ + Kind: types.KindWorkloadIdentityX509Revocation, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "aabbccdd", + Expires: timestamppb.New(srv.Clock().Now().Add(time.Hour)), + }, + Spec: &workloadidentityv1pb.WorkloadIdentityX509RevocationSpec{ + Reason: "compromised", + RevokedAt: timestamppb.New(srv.Clock().Now()), + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityRevocationServiceClient( + tt.client.GetConnection(), + ) + res, err := client.UpsertWorkloadIdentityX509Revocation(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentityX509Revocation, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentityX509Revocation(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityX509RevocationCreate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityX509RevocationCreate{}, "ConnectionMetadata"), + )) + } + }) + } +} diff --git a/lib/services/presets.go b/lib/services/presets.go index ff0d4993f6f..4a74e4bf609 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -210,6 +210,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindAutoUpdateConfig, RW()), types.NewRule(types.KindAutoUpdateAgentRollout, RO()), types.NewRule(types.KindGitServer, RW()), + types.NewRule(types.KindWorkloadIdentityX509Revocation, RW()), }, }, },