[v18] Add Component Feature Advertisement (#62421)

* types: Add `ComponentFeatures` proto types

* feat: Add handling for `ComponentFeatures` to Auth, Proxy, AppServer

- Impl `ComponentFeatures` for Auth, Proxy, AppServer
- Add helper funcs
- Add/update tests
- Update WebUI types

* docs: Update operator crd manifests, tf docs
This commit is contained in:
Maxim
2025-12-30 17:57:55 +00:00
committed by GitHub
parent 894f75cc61
commit 8dfc645a01
27 changed files with 4143 additions and 2588 deletions
@@ -0,0 +1,414 @@
// Code generated by protoc-gen-gogo. DO NOT EDIT.
// source: teleport/componentfeatures/v1/component_features.proto
package componentfeaturesv1
import (
fmt "fmt"
proto "github.com/gogo/protobuf/proto"
io "io"
math "math"
math_bits "math/bits"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package
// ComponentFeatureID is an identifier for a specific feature supported by a Teleport component.
type ComponentFeatureID int32
const (
ComponentFeatureID_COMPONENT_FEATURE_ID_UNSPECIFIED ComponentFeatureID = 0
// ResourceConstraintsV1 indicates support for Resource Constraints and ResourceAccessIDs, as defined in RFD 228.
ComponentFeatureID_COMPONENT_FEATURE_ID_RESOURCE_CONSTRAINTS_V1 ComponentFeatureID = 1
)
var ComponentFeatureID_name = map[int32]string{
0: "COMPONENT_FEATURE_ID_UNSPECIFIED",
1: "COMPONENT_FEATURE_ID_RESOURCE_CONSTRAINTS_V1",
}
var ComponentFeatureID_value = map[string]int32{
"COMPONENT_FEATURE_ID_UNSPECIFIED": 0,
"COMPONENT_FEATURE_ID_RESOURCE_CONSTRAINTS_V1": 1,
}
func (x ComponentFeatureID) String() string {
return proto.EnumName(ComponentFeatureID_name, int32(x))
}
func (ComponentFeatureID) EnumDescriptor() ([]byte, []int) {
return fileDescriptor_a4949687356da643, []int{0}
}
// ComponentFeatures represents a set of features supported by a given Teleport component.
type ComponentFeatures struct {
// features is a list of supported feature identifiers.
Features []ComponentFeatureID `protobuf:"varint,1,rep,packed,name=features,proto3,enum=teleport.componentfeatures.v1.ComponentFeatureID" json:"features,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *ComponentFeatures) Reset() { *m = ComponentFeatures{} }
func (m *ComponentFeatures) String() string { return proto.CompactTextString(m) }
func (*ComponentFeatures) ProtoMessage() {}
func (*ComponentFeatures) Descriptor() ([]byte, []int) {
return fileDescriptor_a4949687356da643, []int{0}
}
func (m *ComponentFeatures) XXX_Unmarshal(b []byte) error {
return m.Unmarshal(b)
}
func (m *ComponentFeatures) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
if deterministic {
return xxx_messageInfo_ComponentFeatures.Marshal(b, m, deterministic)
} else {
b = b[:cap(b)]
n, err := m.MarshalToSizedBuffer(b)
if err != nil {
return nil, err
}
return b[:n], nil
}
}
func (m *ComponentFeatures) XXX_Merge(src proto.Message) {
xxx_messageInfo_ComponentFeatures.Merge(m, src)
}
func (m *ComponentFeatures) XXX_Size() int {
return m.Size()
}
func (m *ComponentFeatures) XXX_DiscardUnknown() {
xxx_messageInfo_ComponentFeatures.DiscardUnknown(m)
}
var xxx_messageInfo_ComponentFeatures proto.InternalMessageInfo
func (m *ComponentFeatures) GetFeatures() []ComponentFeatureID {
if m != nil {
return m.Features
}
return nil
}
func init() {
proto.RegisterEnum("teleport.componentfeatures.v1.ComponentFeatureID", ComponentFeatureID_name, ComponentFeatureID_value)
proto.RegisterType((*ComponentFeatures)(nil), "teleport.componentfeatures.v1.ComponentFeatures")
}
func init() {
proto.RegisterFile("teleport/componentfeatures/v1/component_features.proto", fileDescriptor_a4949687356da643)
}
var fileDescriptor_a4949687356da643 = []byte{
// 256 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x32, 0x2b, 0x49, 0xcd, 0x49,
0x2d, 0xc8, 0x2f, 0x2a, 0xd1, 0x4f, 0xce, 0xcf, 0x2d, 0xc8, 0xcf, 0x4b, 0xcd, 0x2b, 0x49, 0x4b,
0x4d, 0x2c, 0x29, 0x2d, 0x4a, 0x2d, 0xd6, 0x2f, 0x33, 0x44, 0x08, 0xc6, 0xc3, 0x44, 0xf5, 0x0a,
0x8a, 0xf2, 0x4b, 0xf2, 0x85, 0x64, 0x61, 0xfa, 0xf4, 0x30, 0xf4, 0xe9, 0x95, 0x19, 0x2a, 0x25,
0x71, 0x09, 0x3a, 0xc3, 0xc4, 0xdd, 0xa0, 0xe2, 0x42, 0xbe, 0x5c, 0x1c, 0x30, 0x35, 0x12, 0x8c,
0x0a, 0xcc, 0x1a, 0x7c, 0x46, 0x86, 0x7a, 0x78, 0x8d, 0xd1, 0x43, 0x37, 0xc3, 0xd3, 0x25, 0x08,
0x6e, 0x84, 0x56, 0x0e, 0x97, 0x10, 0xa6, 0xbc, 0x90, 0x0a, 0x97, 0x82, 0xb3, 0xbf, 0x6f, 0x80,
0xbf, 0x9f, 0xab, 0x5f, 0x48, 0xbc, 0x9b, 0xab, 0x63, 0x48, 0x68, 0x90, 0x6b, 0xbc, 0xa7, 0x4b,
0x7c, 0xa8, 0x5f, 0x70, 0x80, 0xab, 0xb3, 0xa7, 0x9b, 0xa7, 0xab, 0x8b, 0x00, 0x83, 0x90, 0x01,
0x97, 0x0e, 0x56, 0x55, 0x41, 0xae, 0xc1, 0xfe, 0xa1, 0x41, 0xce, 0xae, 0xf1, 0xce, 0xfe, 0x7e,
0xc1, 0x21, 0x41, 0x8e, 0x9e, 0x7e, 0x21, 0xc1, 0xf1, 0x61, 0x86, 0x02, 0x8c, 0x4e, 0x45, 0x27,
0x1e, 0xc9, 0x31, 0x5e, 0x78, 0x24, 0xc7, 0xf8, 0xe0, 0x91, 0x1c, 0x63, 0x54, 0x4a, 0x7a, 0x66,
0x49, 0x46, 0x69, 0x12, 0xc8, 0xd5, 0xfa, 0xe9, 0x45, 0x89, 0x65, 0x99, 0x25, 0x89, 0x25, 0x99,
0xf9, 0x79, 0x89, 0x39, 0xfa, 0xf0, 0xf0, 0x4c, 0x2c, 0xc8, 0xd4, 0x4f, 0x4f, 0xcd, 0xd3, 0x07,
0x87, 0x97, 0x7e, 0x7a, 0xbe, 0x3e, 0xde, 0x90, 0xb6, 0xc6, 0x10, 0x2c, 0x33, 0x4c, 0x62, 0x03,
0xeb, 0x35, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0x72, 0x26, 0xf7, 0x8d, 0xa5, 0x01, 0x00, 0x00,
}
func (m *ComponentFeatures) Marshal() (dAtA []byte, err error) {
size := m.Size()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBuffer(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *ComponentFeatures) MarshalTo(dAtA []byte) (int, error) {
size := m.Size()
return m.MarshalToSizedBuffer(dAtA[:size])
}
func (m *ComponentFeatures) MarshalToSizedBuffer(dAtA []byte) (int, error) {
i := len(dAtA)
_ = i
var l int
_ = l
if m.XXX_unrecognized != nil {
i -= len(m.XXX_unrecognized)
copy(dAtA[i:], m.XXX_unrecognized)
}
if len(m.Features) > 0 {
dAtA2 := make([]byte, len(m.Features)*10)
var j1 int
for _, num := range m.Features {
for num >= 1<<7 {
dAtA2[j1] = uint8(uint64(num)&0x7f | 0x80)
num >>= 7
j1++
}
dAtA2[j1] = uint8(num)
j1++
}
i -= j1
copy(dAtA[i:], dAtA2[:j1])
i = encodeVarintComponentFeatures(dAtA, i, uint64(j1))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func encodeVarintComponentFeatures(dAtA []byte, offset int, v uint64) int {
offset -= sovComponentFeatures(v)
base := offset
for v >= 1<<7 {
dAtA[offset] = uint8(v&0x7f | 0x80)
v >>= 7
offset++
}
dAtA[offset] = uint8(v)
return base
}
func (m *ComponentFeatures) Size() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if len(m.Features) > 0 {
l = 0
for _, e := range m.Features {
l += sovComponentFeatures(uint64(e))
}
n += 1 + sovComponentFeatures(uint64(l)) + l
}
if m.XXX_unrecognized != nil {
n += len(m.XXX_unrecognized)
}
return n
}
func sovComponentFeatures(x uint64) (n int) {
return (math_bits.Len64(x|1) + 6) / 7
}
func sozComponentFeatures(x uint64) (n int) {
return sovComponentFeatures(uint64((x << 1) ^ uint64((int64(x) >> 63))))
}
func (m *ComponentFeatures) Unmarshal(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: ComponentFeatures: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: ComponentFeatures: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType == 0 {
var v ComponentFeatureID
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= ComponentFeatureID(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.Features = append(m.Features, v)
} else if wireType == 2 {
var packedLen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
packedLen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if packedLen < 0 {
return ErrInvalidLengthComponentFeatures
}
postIndex := iNdEx + packedLen
if postIndex < 0 {
return ErrInvalidLengthComponentFeatures
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
var elementCount int
if elementCount != 0 && len(m.Features) == 0 {
m.Features = make([]ComponentFeatureID, 0, elementCount)
}
for iNdEx < postIndex {
var v ComponentFeatureID
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= ComponentFeatureID(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.Features = append(m.Features, v)
}
} else {
return fmt.Errorf("proto: wrong wireType = %d for field Features", wireType)
}
default:
iNdEx = preIndex
skippy, err := skipComponentFeatures(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLengthComponentFeatures
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.XXX_unrecognized = append(m.XXX_unrecognized, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skipComponentFeatures(dAtA []byte) (n int, err error) {
l := len(dAtA)
iNdEx := 0
depth := 0
for iNdEx < l {
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
wireType := int(wire & 0x7)
switch wireType {
case 0:
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
iNdEx++
if dAtA[iNdEx-1] < 0x80 {
break
}
}
case 1:
iNdEx += 8
case 2:
var length int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowComponentFeatures
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
length |= (int(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
if length < 0 {
return 0, ErrInvalidLengthComponentFeatures
}
iNdEx += length
case 3:
depth++
case 4:
if depth == 0 {
return 0, ErrUnexpectedEndOfGroupComponentFeatures
}
depth--
case 5:
iNdEx += 4
default:
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
}
if iNdEx < 0 {
return 0, ErrInvalidLengthComponentFeatures
}
if depth == 0 {
return iNdEx, nil
}
}
return 0, io.ErrUnexpectedEOF
}
var (
ErrInvalidLengthComponentFeatures = fmt.Errorf("proto: negative length found during unmarshaling")
ErrIntOverflowComponentFeatures = fmt.Errorf("proto: integer overflow")
ErrUnexpectedEndOfGroupComponentFeatures = fmt.Errorf("proto: unexpected end of group")
)
@@ -0,0 +1,32 @@
// Copyright 2025 Gravitational, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package teleport.componentfeatures.v1;
option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1;componentfeaturesv1";
// ComponentFeatureID is an identifier for a specific feature supported by a Teleport component.
enum ComponentFeatureID {
COMPONENT_FEATURE_ID_UNSPECIFIED = 0;
// ResourceConstraintsV1 indicates support for Resource Constraints and ResourceAccessIDs, as defined in RFD 228.
COMPONENT_FEATURE_ID_RESOURCE_CONSTRAINTS_V1 = 1;
}
// ComponentFeatures represents a set of features supported by a given Teleport component.
message ComponentFeatures {
// features is a list of supported feature identifiers.
repeated ComponentFeatureID features = 1;
}
@@ -21,6 +21,7 @@ import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "teleport/attestation/v1/attestation.proto";
import "teleport/componentfeatures/v1/component_features.proto";
import "teleport/legacy/types/trusted_device_requirement.proto";
import "teleport/legacy/types/wrappers/wrappers.proto";
@@ -972,6 +973,9 @@ message ServerSpecV2 {
// the list of Relay host IDs that the server is connected to
repeated string relay_ids = 17;
// component_features represents features supported by this server
teleport.componentfeatures.v1.ComponentFeatures component_features = 18;
reserved 8;
reserved 10;
reserved "KubernetesClusters";
@@ -1060,6 +1064,9 @@ message AppServerSpecV3 {
string relay_group = 7;
// the list of Relay host IDs that the server is connected to
repeated string relay_ids = 8;
// component_features contains features supported by this app server.
teleport.componentfeatures.v1.ComponentFeatures component_features = 9;
}
// AppV3List represents a list of app resources.
+15
View File
@@ -27,6 +27,7 @@ import (
"github.com/gravitational/teleport/api"
"github.com/gravitational/teleport/api/constants"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/iterutils"
)
@@ -70,6 +71,10 @@ type AppServer interface {
GetRelayIDs() []string
// GetScope returns the scope this server belongs to.
GetScope() string
// GetComponentFeatures returns the ComponentFeatures supported by this AppServer.
GetComponentFeatures() *componentfeaturesv1.ComponentFeatures
// SetComponentFeatures sets the ComponentFeatures supported by this AppServer.
SetComponentFeatures(*componentfeaturesv1.ComponentFeatures)
}
// NewAppServerV3 creates a new app server instance.
@@ -114,6 +119,16 @@ func NewAppServerForAWSOIDCIntegration(integrationName, hostID, publicAddr strin
})
}
// GetComponentFeatures returns the ComponentFeatures supported by this AppServer.
func (s *AppServerV3) GetComponentFeatures() *componentfeaturesv1.ComponentFeatures {
return s.Spec.ComponentFeatures
}
// SetComponentFeatures sets the ComponentFeatures supported by this AppServer.
func (s *AppServerV3) SetComponentFeatures(cf *componentfeaturesv1.ComponentFeatures) {
s.Spec.ComponentFeatures = cf
}
// GetVersion returns the database server resource version.
func (s *AppServerV3) GetVersion() string {
return s.Version
+16
View File
@@ -27,6 +27,7 @@ import (
"github.com/google/uuid"
"github.com/gravitational/trace"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/aws"
)
@@ -114,6 +115,11 @@ type Server interface {
GetGitHub() *GitHubServerMetadata
// GetScope returns the scope this server belongs to.
GetScope() string
// GetComponentFeatures returns the supported features for the server.
GetComponentFeatures() *componentfeaturesv1.ComponentFeatures
// SetComponentFeatures sets the supported features for the server.
SetComponentFeatures(*componentfeaturesv1.ComponentFeatures)
}
// NewServer creates an instance of Server.
@@ -195,6 +201,16 @@ func NewGitHubServerWithName(name string, githubSpec GitHubServerMetadata) (Serv
return server, nil
}
// GetComponentFeatures returns the supported features for the server.
func (s *ServerV2) GetComponentFeatures() *componentfeaturesv1.ComponentFeatures {
return s.Spec.ComponentFeatures
}
// SetComponentFeatures sets the supported features for the server.
func (s *ServerV2) SetComponentFeatures(features *componentfeaturesv1.ComponentFeatures) {
s.Spec.ComponentFeatures = features
}
// GetVersion returns resource version
func (s *ServerV2) GetVersion() string {
return s.Version
+2699 -2583
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@ inputs:
exclude_paths:
# generated by buf-gogo.gen.yaml
- api/proto/teleport/attestation/
- api/proto/teleport/componentfeatures/
- api/proto/teleport/legacy/
- api/proto/teleport/mfa/
- api/proto/teleport/usageevents/
+1
View File
@@ -4,6 +4,7 @@ inputs:
- directory: .
paths:
- api/proto/teleport/attestation/
- api/proto/teleport/componentfeatures/
- api/proto/teleport/legacy/
- api/proto/teleport/mfa/
- api/proto/teleport/usageevents/
+2
View File
@@ -43,6 +43,8 @@ var ignoredFields = map[string]stringSet{
// Useless field for agentless servers, and potentially dangerous as it
// allows remote exec on agentful nodes.
"CmdLabels": struct{}{},
// Not intended for manual usage.
"component_features": struct{}{},
},
"TrustedClusterSpecV2": {
"Roles": struct{}{}, // Deprecated, use RoleMap instead.
@@ -178,6 +178,7 @@ exclude_fields:
# Server
- "ServerSpecV2.CmdLabels"
- "ServerSpecV2.component_features"
# SessionRecordingConfig
- "SessionRecordingConfigV2.Metadata.Name" # It's a singleton resource
@@ -28,6 +28,7 @@ import (
proto "github.com/gogo/protobuf/proto"
github_com_gravitational_teleport_api_constants "github.com/gravitational/teleport/api/constants"
_ "github.com/gravitational/teleport/api/gen/proto/go/attestation/v1"
_ "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
github_com_gravitational_teleport_api_types "github.com/gravitational/teleport/api/types"
github_com_hashicorp_terraform_plugin_framework_attr "github.com/hashicorp/terraform-plugin-framework/attr"
github_com_hashicorp_terraform_plugin_framework_diag "github.com/hashicorp/terraform-plugin-framework/diag"
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package componentfeatures
import (
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
"github.com/gravitational/teleport/api/types"
)
// ForAuthServer returns features that an Auth server can support/participate in.
func ForAuthServer() *componentfeaturesv1.ComponentFeatures {
return New(FeatureResourceConstraintsV1)
}
type sshServerInfoGetter interface {
GetProxyMode() bool
}
// ForSSHServer returns features that an SSH/Proxy server can support/participate in.
func ForSSHServer(g sshServerInfoGetter) *componentfeaturesv1.ComponentFeatures {
// Resource Constraints are only supported for Proxy servers.
if !g.GetProxyMode() {
return New()
}
return New(FeatureResourceConstraintsV1)
}
type appServerInfoGetter interface {
GetApp() types.Application
}
// ForAppServer returns features that an App server can support/participate in.
func ForAppServer(g appServerInfoGetter) *componentfeaturesv1.ComponentFeatures {
// Resource Constraints are only supported for AWS Console apps.
if app := g.GetApp(); !app.IsAWSConsole() {
return New()
}
return New(FeatureResourceConstraintsV1)
}
+56
View File
@@ -0,0 +1,56 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package componentfeatures
import (
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
)
// FeatureID is used to wrap [componentfeaturesv1.ComponentFeatureID] for convenience methods.
type FeatureID int32
const (
// FeatureUnspecified is the unspecified feature ID.
FeatureUnspecified FeatureID = iota
// FeatureResourceConstraintsV1 indicates support for Resource Constraints in Access Requests,
// identity certificates, and AWS Console App resources.
FeatureResourceConstraintsV1
)
var featureIDToName = map[FeatureID]string{
FeatureUnspecified: "UNSPECIFIED",
FeatureResourceConstraintsV1: "RESOURCE_CONSTRAINTS_V1",
}
// String returns a short name for the FeatureID, falling back to the
// [componentfeaturesv1.ComponentFeatureID] enum name if not specified.
func (f FeatureID) String() string {
if s, ok := featureIDToName[f]; ok {
return s
}
if s, ok := componentfeaturesv1.ComponentFeatureID_name[int32(f)]; ok {
return s
}
return "UNKNOWN"
}
// ToProto converts the FeatureID to its corresponding [componentfeaturesv1.ComponentFeatureID].
func (f FeatureID) ToProto() componentfeaturesv1.ComponentFeatureID {
return componentfeaturesv1.ComponentFeatureID(f)
}
+139
View File
@@ -0,0 +1,139 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package componentfeatures
import componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
// New creates a new [componentfeaturesv1.ComponentFeatures] struct containing the provided FeatureIDs.
func New(features ...FeatureID) *componentfeaturesv1.ComponentFeatures {
out := &componentfeaturesv1.ComponentFeatures{}
seen := make(map[FeatureID]struct{})
for _, f := range features {
if _, exists := seen[f]; exists {
continue
}
seen[f] = struct{}{}
out.Features = append(out.Features, f.ToProto())
}
return out
}
// Join copies, deduplicates, and combines [componentfeaturesv1.ComponentFeatures] sets into a single set
// containing all unique features.
func Join(sets ...*componentfeaturesv1.ComponentFeatures) *componentfeaturesv1.ComponentFeatures {
out := &componentfeaturesv1.ComponentFeatures{}
seen := make(map[componentfeaturesv1.ComponentFeatureID]struct{})
for _, fs := range sets {
if fs == nil || len(fs.GetFeatures()) == 0 {
continue
}
for _, f := range fs.GetFeatures() {
if _, exists := seen[f]; !exists {
seen[f] = struct{}{}
out.Features = append(out.Features, f)
}
}
}
return out
}
// InAllSets reports whether a given [componentfeaturesv1.ComponentFeatureID] is
// present in *every* [componentfeaturesv1.ComponentFeatures] set.
//
// If no sets are provided, or any set is nil, it returns false.
func InAllSets(feature FeatureID, sets ...*componentfeaturesv1.ComponentFeatures) bool {
if len(sets) == 0 {
return false
}
proto := feature.ToProto()
for _, fs := range sets {
if fs == nil || len(fs.GetFeatures()) == 0 {
return false
}
found := false
for _, f := range fs.GetFeatures() {
if f == proto {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// Intersect returns a new [componentfeaturesv1.ComponentFeatures] containing only features present in all input
// [componentfeaturesv1.ComponentFeatures].
//
// If no sets are provided, or any set is nil or empty, it returns an empty [componentfeaturesv1.ComponentFeatures].
func Intersect(sets ...*componentfeaturesv1.ComponentFeatures) *componentfeaturesv1.ComponentFeatures {
out := &componentfeaturesv1.ComponentFeatures{}
if len(sets) == 0 {
return out
}
counts := make(map[componentfeaturesv1.ComponentFeatureID]int)
for _, fs := range sets {
if fs == nil || len(fs.GetFeatures()) == 0 {
return out
}
seenInThisSet := make(map[componentfeaturesv1.ComponentFeatureID]struct{})
for _, f := range fs.GetFeatures() {
if _, seen := seenInThisSet[f]; seen {
continue
}
seenInThisSet[f] = struct{}{}
counts[f]++
}
}
for f, c := range counts {
if c == len(sets) {
out.Features = append(out.Features, f)
}
}
return out
}
// ToIntegers deduplicates and converts [componentfeaturesv1.ComponentFeatures] into a list of integers.
func ToIntegers(features *componentfeaturesv1.ComponentFeatures) []int {
out := make([]int, 0)
seen := make(map[componentfeaturesv1.ComponentFeatureID]struct{})
for _, f := range features.GetFeatures() {
if _, seen := seen[f]; seen {
continue
}
seen[f] = struct{}{}
out = append(out, int(f))
}
return out
}
+254
View File
@@ -0,0 +1,254 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package componentfeatures
import (
"testing"
"github.com/stretchr/testify/require"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
)
func TestNew(t *testing.T) {
t.Parallel()
const (
featA = FeatureResourceConstraintsV1
featB = FeatureID(200)
)
tests := []struct {
name string
features []FeatureID
want []componentfeaturesv1.ComponentFeatureID
}{
{
name: "no features yields empty set",
features: []FeatureID{},
want: []componentfeaturesv1.ComponentFeatureID{},
},
{
name: "duplicates are ignored",
features: []FeatureID{featA, featB, featA, featB},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto()},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := New(tt.features...)
require.NotNil(t, got, "New(%v) returned nil", tt.features)
require.ElementsMatch(t, tt.want, got.Features, "New(%v) = %v; want %v", tt.features, got.Features, tt.want)
})
}
}
func TestJoin(t *testing.T) {
t.Parallel()
const (
featA = FeatureResourceConstraintsV1
featB = FeatureID(200)
featC = FeatureID(201)
)
tests := []struct {
name string
sets []*componentfeaturesv1.ComponentFeatures
want []componentfeaturesv1.ComponentFeatureID
}{
{
name: "no sets returns empty set",
sets: nil,
want: []componentfeaturesv1.ComponentFeatureID{},
},
{
name: "single set returns same set",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto()},
},
{
name: "multiple sets combined",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA), New(featB), New(featA, featC)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto(), featC.ToProto()},
},
{
name: "nil and empty sets ignored",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA), nil, New(), New(featB)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto()},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Join(tt.sets...)
require.NotNil(t, got, "Join(%v) returned nil", tt.sets)
require.ElementsMatch(t, tt.want, got.Features, "Join(%v) = %v; want %v", tt.sets, got.Features, tt.want)
})
}
}
func TestInAllSets(t *testing.T) {
t.Parallel()
const (
featA = FeatureResourceConstraintsV1
featB = FeatureID(200)
featC = FeatureID(201)
)
tests := []struct {
name string
sets []*componentfeaturesv1.ComponentFeatures
feature FeatureID
want bool
}{
{
name: "no sets",
sets: nil,
feature: featA,
want: false,
},
{
name: "single set contains feature",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA)},
feature: featA,
want: true,
},
{
name: "single set missing feature",
sets: []*componentfeaturesv1.ComponentFeatures{New(featB)},
feature: featA,
want: false,
},
{
name: "multiple sets all contain feature",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB), New(featA), New(featA, featB, featC)},
feature: featA,
want: true,
},
{
name: "one set missing feature",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA), New(featA, featB), New(featB, featC)},
feature: featA,
want: false,
},
{
name: "nil set treated as unsupported",
sets: []*componentfeaturesv1.ComponentFeatures{New(featB, featC), nil, New(featA, featB)},
feature: featB,
want: false,
},
{
name: "empty set treated as unsupported",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA), New(), New(featA, featB)},
feature: featA,
want: false,
},
{
name: "duplicate feature in set",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featA, featB), New(featA, featC, featC)},
feature: featA,
want: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := InAllSets(tt.feature, tt.sets...)
require.Equal(t, tt.want, got, "FeatureInAllSets(%v, %v) = %v; want %v", tt.feature, tt.sets, got, tt.want)
})
}
}
func TestIntersect(t *testing.T) {
t.Parallel()
const (
featA = FeatureResourceConstraintsV1
featB = FeatureID(200)
featC = FeatureID(201)
featD = FeatureID(202)
)
tests := []struct {
name string
sets []*componentfeaturesv1.ComponentFeatures
want []componentfeaturesv1.ComponentFeatureID
}{
{
name: "no sets returns empty set",
sets: nil,
want: []componentfeaturesv1.ComponentFeatureID{},
},
{
name: "nil set returns empty set",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB), nil, New(featA, featC)},
want: []componentfeaturesv1.ComponentFeatureID{},
},
{
name: "disjoint sets return empty set",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB), New(featC, featD)},
want: []componentfeaturesv1.ComponentFeatureID{},
},
{
name: "single set returns same set",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto()},
},
{
name: "sets with full intersection",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB, featD), New(featD, featA, featB), New(featB, featD, featA)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto(), featB.ToProto(), featD.ToProto()},
},
{
name: "sets with partial intersection",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB, featC), New(featB, featC, featD), New(featB, featD)},
want: []componentfeaturesv1.ComponentFeatureID{featB.ToProto()},
},
{
name: "duplicate features do not affect intersection",
sets: []*componentfeaturesv1.ComponentFeatures{New(featA, featB, featB), New(featA, featA, featB, featC), New(featA, featD)},
want: []componentfeaturesv1.ComponentFeatureID{featA.ToProto()},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Intersect(tt.sets...)
require.NotNil(t, got, "Intersect should not return nil")
// Order not guaranteed, so compare as sets.
require.ElementsMatch(t, tt.want, got.Features, "Intersect(%v) = %v; want %v", tt.sets, got.Features, tt.want)
})
}
}
+5 -3
View File
@@ -120,6 +120,7 @@ import (
"github.com/gravitational/teleport/lib/cloud/imds/azure"
gcpimds "github.com/gravitational/teleport/lib/cloud/imds/gcp"
oracleimds "github.com/gravitational/teleport/lib/cloud/imds/oracle"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/events/athena"
@@ -2774,9 +2775,10 @@ func (process *TeleportProcess) initAuthService() error {
Name: connector.HostUUID(),
},
Spec: types.ServerSpecV2{
Addr: authAddr,
Hostname: process.Config.Hostname,
Version: teleport.Version,
Addr: authAddr,
Hostname: process.Config.Hostname,
Version: teleport.Version,
ComponentFeatures: componentfeatures.ForAuthServer(),
},
}
state, err := process.storage.GetState(process.GracefulExitContext(), types.RoleAdmin)
+48
View File
@@ -33,9 +33,11 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/client/proto"
apidefaults "github.com/gravitational/teleport/api/defaults"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
)
@@ -954,6 +956,14 @@ func newResourceCollection(r resource) resourceCollection {
status: aggregateHealthStatuses(servers),
}
})
case types.AppServer:
return newServerResourceCollection(r,
func(srv types.AppServer, servers map[string]types.AppServer) types.AppServer {
return &aggregatedAppServer{
AppServer: srv,
features: intersectComponentFeaturesForAppServers(servers),
}
})
case serverResource:
return newServerResourceCollection(r, nil)
default:
@@ -1037,6 +1047,44 @@ func (c *serverResourceCollection[R]) remove(r types.Resource) bool {
return true
}
// aggregatedAppServer wraps an app server with aggregated ComponentFeatures
// in order to perform an intersection of all features reported by multiple
// AppServers serving the same app. Only ComponentFeatures supported by *all*
// AppServers will be reported in the aggregated Resource.
type aggregatedAppServer struct {
types.AppServer
features *componentfeaturesv1.ComponentFeatures
}
// Copy returns a copy of the underlying app server with aggregated
// [componentfeaturesv1.ComponentFeatures].
func (a *aggregatedAppServer) Copy() types.AppServer {
out := a.AppServer.Copy()
out.SetComponentFeatures(a.GetComponentFeatures())
return out
}
// CloneResource returns a copy of the underlying app server with
// aggregated [componentfeaturesv1.ComponentFeatures].
func (a *aggregatedAppServer) CloneResource() types.ResourceWithLabels {
return a.Copy()
}
func (a *aggregatedAppServer) GetComponentFeatures() *componentfeaturesv1.ComponentFeatures {
if a.features == nil {
return nil
}
return componentfeatures.Join(a.features)
}
func intersectComponentFeaturesForAppServers(servers map[string]types.AppServer) *componentfeaturesv1.ComponentFeatures {
allFeatures := make([]*componentfeaturesv1.ComponentFeatures, 0, len(servers))
for _, s := range servers {
allFeatures = append(allFeatures, s.GetComponentFeatures())
}
return componentfeatures.Intersect(allFeatures...)
}
// aggregatedDatabase wraps a database server with aggregated health status.
// It is assumed that multiple heartbeats with the same resource name but
// different host IDs may be received and they may report different health
+124
View File
@@ -37,6 +37,7 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/defaults"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1"
"github.com/gravitational/teleport/api/types"
@@ -44,6 +45,7 @@ import (
"github.com/gravitational/teleport/api/types/header"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
)
@@ -193,6 +195,7 @@ func TestUnifiedResourceWatcher(t *testing.T) {
cmpopts.EquateEmpty(),
cmpopts.IgnoreFields(types.Metadata{}, "Revision"),
cmpopts.IgnoreFields(header.Metadata{}, "Revision"),
cmpopts.IgnoreFields(types.AppServerSpecV3{}, "ComponentFeatures"),
// Ignore order.
cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }),
))
@@ -225,6 +228,7 @@ func TestUnifiedResourceWatcher(t *testing.T) {
cmpopts.EquateEmpty(),
cmpopts.IgnoreFields(types.Metadata{}, "Revision"),
cmpopts.IgnoreFields(header.Metadata{}, "Revision"),
cmpopts.IgnoreFields(types.AppServerSpecV3{}, "ComponentFeatures"),
// Ignore order.
cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }),
@@ -337,6 +341,7 @@ func TestUnifiedResourceCacheIterateResources(t *testing.T) {
compareResourceOpts := []cmp.Option{cmpopts.EquateEmpty(),
cmpopts.IgnoreFields(types.Metadata{}, "Revision"),
cmpopts.IgnoreFields(header.Metadata{}, "Revision"),
cmpopts.IgnoreFields(types.AppServerSpecV3{}, "ComponentFeatures"),
// Ignore order.
cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }),
@@ -843,6 +848,124 @@ func TestUnifiedResourceWatcher_PreventDuplicates(t *testing.T) {
}
func TestUnifiedResourceCache_AppServerComponentFeaturesIntersection(t *testing.T) {
t.Parallel()
ctx := t.Context()
const appName = "aws-console-app"
makeAppServer := func(
t *testing.T,
hostID string,
features *componentfeaturesv1.ComponentFeatures,
) *types.AppServerV3 {
t.Helper()
srv, err := types.NewAppServerV3(
types.Metadata{Name: appName},
types.AppServerSpecV3{
HostID: hostID,
App: newApp(t, appName),
},
)
require.NoError(t, err)
if features != nil {
srv.SetComponentFeatures(features)
}
return srv
}
findAppServer := func(
t *testing.T,
w *services.UnifiedResourceCache,
appName string,
) types.AppServer {
t.Helper()
var (
aggregatedServer types.AppServer
count int
)
for srv, err := range w.AppServers(ctx, services.UnifiedResourcesIterateParams{}) {
require.NoError(t, err)
if srv.GetApp() == nil || srv.GetApp().GetName() != appName {
continue
}
aggregatedServer = srv
count++
}
require.Equal(t, 1, count, "expected exactly one AppServer for app %q", appName)
require.NotNil(t, aggregatedServer, "expected non-nil AppServer for app %q", appName)
return aggregatedServer
}
createUnifiedResourceCache := func(t *testing.T, clt *client) *services.UnifiedResourceCache {
t.Helper()
w, err := services.NewUnifiedResourceCache(ctx, services.UnifiedResourceCacheConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Component: teleport.ComponentUnifiedResource,
Client: clt,
},
ResourceGetter: clt,
})
require.NoError(t, err)
assert.Eventually(t, func() bool {
return w.IsInitialized()
}, 5*time.Second, 10*time.Millisecond, "unified resource watcher never initialized")
return w
}
t.Run("feature advertised by only one AppServer is not advertised by the aggregated AppServer", func(t *testing.T) {
t.Parallel()
clt := newClient(t)
shared := componentfeatures.FeatureResourceConstraintsV1
onlyOnOne := componentfeatures.FeatureID(9999)
appSrv1 := makeAppServer(t, "host-1", componentfeatures.New(shared, onlyOnOne))
_, err := clt.UpsertApplicationServer(ctx, appSrv1)
require.NoError(t, err)
appSrv2 := makeAppServer(t, "host-2", componentfeatures.New(shared))
_, err = clt.UpsertApplicationServer(ctx, appSrv2)
require.NoError(t, err)
w := createUnifiedResourceCache(t, clt)
// There should be a single AppServer for this app, with features = intersection(shared, onlyOnOne) = shared
aggregatedServer := findAppServer(t, w, appName)
cf := aggregatedServer.GetComponentFeatures()
require.NotNil(t, cf, "aggregated AppServer should have non-nil ComponentFeatures")
require.ElementsMatch(t, []componentfeaturesv1.ComponentFeatureID{
shared.ToProto(),
}, cf.GetFeatures(), fmt.Sprintf("expected intersection to contain only '%s'", shared.String()))
})
t.Run("any empty or nil feature set yields empty intersection", func(t *testing.T) {
t.Parallel()
clt := newClient(t)
shared := componentfeatures.FeatureResourceConstraintsV1
appSrv1 := makeAppServer(t, "host-1", componentfeatures.New(shared))
_, err := clt.UpsertApplicationServer(ctx, appSrv1)
require.NoError(t, err)
appSrv2 := makeAppServer(t, "host-2", nil)
_, err = clt.UpsertApplicationServer(ctx, appSrv2)
require.NoError(t, err)
w := createUnifiedResourceCache(t, clt)
// There should be a single AppServer for this app, with features = intersection(shared, empty) = empty
aggregatedServer := findAppServer(t, w, appName)
cf := aggregatedServer.GetComponentFeatures()
require.NotNil(t, cf, "aggregated AppServer should have non-nil ComponentFeatures")
require.Empty(t, cf.Features, "expected empty intersection when one of the feature sets is nil or empty")
})
}
func TestUnifiedResourceWatcher_DeleteEvent(t *testing.T) {
t.Parallel()
@@ -1218,6 +1341,7 @@ func TestUnifiedResourceCacheIterateMCPServers(t *testing.T) {
compareResourceOpts := []cmp.Option{cmpopts.EquateEmpty(),
cmpopts.IgnoreFields(types.Metadata{}, "Revision"),
cmpopts.IgnoreFields(header.Metadata{}, "Revision"),
cmpopts.IgnoreFields(types.AppServerSpecV3{}, "ComponentFeatures"),
// Ignore order.
cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }),
+7 -1
View File
@@ -35,6 +35,7 @@ import (
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/inventory"
"github.com/gravitational/teleport/lib/labels"
"github.com/gravitational/teleport/lib/reversetunnel"
@@ -351,8 +352,13 @@ func (s *Server) getServerInfo(app types.Application) (*types.AppServerV3, error
App: copy,
ProxyIDs: s.c.ConnectedProxyGetter.GetProxyIDs(),
})
if err != nil {
return nil, trace.Wrap(err)
}
return server, trace.Wrap(err)
server.SetComponentFeatures(componentfeatures.ForAppServer(server))
return server, nil
}
// getRotationState is a helper to return this server's CA rotation state.
+1 -1
View File
@@ -503,7 +503,7 @@ func TestStart(t *testing.T) {
sort.Sort(types.AppServers(servers))
require.Empty(t, cmp.Diff([]types.AppServer{serverAWS, serverAWSWithIntegration, serverFoo}, servers,
cmpopts.IgnoreFields(types.Metadata{}, "Revision", "Expires")))
cmpopts.IgnoreFields(types.Metadata{}, "Revision", "Expires"), cmpopts.IgnoreFields(types.AppServerSpecV3{}, "ComponentFeatures")))
// Check the expiry time is correct.
for _, server := range servers {
+7
View File
@@ -54,6 +54,7 @@ import (
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/bpf"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/inventory"
@@ -353,6 +354,11 @@ func (s *Server) GetSELinuxEnabled() bool {
return s.enableSELinux
}
// GetProxyMode returns whether the server is started in SSH proxying mode.
func (s *Server) GetProxyMode() bool {
return s.proxyMode
}
// ChildLogConfig returns the child log config.
func (s *Server) ChildLogConfig() srv.ChildLogConfig {
if s.childLogConfig != nil {
@@ -1188,6 +1194,7 @@ func (s *Server) getBasicInfo() *types.ServerV2 {
},
}
srv.SetPublicAddrs(utils.NetAddrsToStrings(s.publicAddrs))
srv.SetComponentFeatures(componentfeatures.ForSSHServer(s))
return srv
}
+55
View File
@@ -65,6 +65,7 @@ import (
"github.com/gravitational/teleport/api/client/webclient"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1"
summarizerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/summarizer/v1"
@@ -87,6 +88,7 @@ import (
"github.com/gravitational/teleport/lib/client"
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/defaults"
dtconfig "github.com/gravitational/teleport/lib/devicetrust/config"
"github.com/gravitational/teleport/lib/events"
@@ -3409,6 +3411,8 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
getUserGroupLookup := h.getUserGroupLookup(request.Context(), clt)
clusterAuthProxyFeatures := h.getAuthProxyComponentFeaturesIntersection(request.Context())
unifiedResources := make([]any, 0, len(page))
for _, enriched := range page {
switch r := enriched.ResourceWithLabels.(type) {
@@ -3441,6 +3445,11 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
proxyDNSName = utils.FindMatchingProxyDNS(request.Host, h.proxyDNSNames())
}
// Compute end-to-end feature support for this app: only features that are supported by the AppServer *and*
// by all required cluster hops (Auth + Proxy), so clients can hide features that would fail somewhere
// along the request path.
appComponentFeatures := componentfeatures.Intersect(r.GetComponentFeatures(), clusterAuthProxyFeatures)
app := ui.MakeApp(r.GetApp(), ui.MakeAppsConfig{
LocalClusterName: h.auth.clusterName,
LocalProxyDNSName: proxyDNSName,
@@ -3449,6 +3458,7 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
UserGroupLookup: getUserGroupLookup(),
Logger: h.logger,
RequiresRequest: enriched.RequiresRequest,
SupportedFeatures: appComponentFeatures,
})
unifiedResources = append(unifiedResources, app)
case types.SAMLIdPServiceProvider:
@@ -3486,6 +3496,51 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
return resp, nil
}
// getAuthProxyComponentFeaturesIntersection returns an intersection of supported ComponentFeatures
// from all cluster Proxy and Auth servers.
func (h *Handler) getAuthProxyComponentFeaturesIntersection(ctx context.Context) *componentfeaturesv1.ComponentFeatures {
ap := h.GetAccessPoint()
features := make([]*componentfeaturesv1.ComponentFeatures, 0)
allProxies, err := clientutils.CollectWithFallback(
ctx,
ap.ListProxyServers,
func(context.Context) ([]types.Server, error) {
//nolint:staticcheck // TODO(kiosion): DELETE IN 21.0.0
return ap.GetProxies()
},
)
if err != nil {
// If we fail to get proxies & can't be sure about feature support,
// intersecting on `nil` ensures any intersection of ComponentFeatures will be empty.
h.logger.ErrorContext(ctx, "Failed to get proxy servers to collect ComponentFeatures", "error", err)
features = append(features, nil)
} else {
for _, srv := range allProxies {
features = append(features, srv.GetComponentFeatures())
}
}
allAuthServers, err := clientutils.CollectWithFallback(
ctx,
ap.ListAuthServers,
func(context.Context) ([]types.Server, error) {
//nolint:staticcheck // TODO(kiosion): DELETE IN 21.0.0
return ap.GetAuthServers()
},
)
if err != nil {
h.logger.ErrorContext(ctx, "Failed to get auth servers to collect ComponentFeatures", "error", err)
features = append(features, nil)
} else {
for _, srv := range allAuthServers {
features = append(features, srv.GetComponentFeatures())
}
}
return componentfeatures.Intersect(features...)
}
// clusterNodesGet returns a list of nodes for a given cluster site.
func (h *Handler) clusterNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
// Get a client to the Auth Server with the logged in user's identity. The
+116
View File
@@ -93,6 +93,7 @@ import (
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/clientutils"
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
"github.com/gravitational/teleport/api/utils/keys"
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
@@ -114,6 +115,7 @@ import (
"github.com/gravitational/teleport/lib/client/conntest"
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/cryptosuites/cryptosuitestest"
"github.com/gravitational/teleport/lib/defaults"
@@ -1293,6 +1295,120 @@ func TestUserGroupsGet(t *testing.T) {
}})
}
func TestUnifiedResourcesGet_AppComponentFeatures(t *testing.T) {
t.Parallel()
env := newWebPack(t, 1)
proxy := env.proxies[0]
ctx := t.Context()
username := "[email protected]"
u, err := user.Current()
require.NoError(t, err)
loginUser := u.Username
role := defaultRoleForNewUser(&types.UserV2{Metadata: types.Metadata{Name: username}}, loginUser)
role.SetAppLabels(types.Allow, types.Labels{"env": []string{"prod"}})
pack := proxy.authPack(t, username, []types.Role{role})
// - cluster (auth/proxy) support `feature1`
// - app has 2 appservers, both support `feature1` + `feature2`
// - aggregated appserver features should = {feature1, feature2}
// - final app supportedFeatureIDs should = {feature1}
feature1 := componentfeatures.FeatureID(9998)
feature2 := componentfeatures.FeatureID(9999)
{
for srv := range clientutils.Resources(ctx, env.server.Auth().ListProxyServers) {
srv.SetComponentFeatures(componentfeatures.New(feature1))
err := env.server.Auth().UpsertProxy(ctx, srv)
require.NoError(t, err)
}
}
{
for srv := range clientutils.Resources(ctx, env.server.Auth().ListAuthServers) {
srv.SetComponentFeatures(componentfeatures.New(feature1))
err := env.server.Auth().UpsertAuthServer(ctx, srv)
require.NoError(t, err)
}
}
// AWS Console app + 2 app servers serving it
appName := "aws-console-app"
awsApp, err := types.NewAppV3(
types.Metadata{
Name: appName,
Labels: map[string]string{
"env": "prod",
},
},
types.AppSpecV3{
URI: "https://console.aws.amazon.com",
Cloud: "AWS",
},
)
require.NoError(t, err)
awsSrv1, err := types.NewAppServerV3FromApp(awsApp, "localhost", "host-1")
require.NoError(t, err)
awsSrv1.SetComponentFeatures(componentfeatures.New(feature1, feature2))
_, err = env.server.Auth().UpsertApplicationServer(ctx, awsSrv1)
require.NoError(t, err)
awsSrv2, err := types.NewAppServerV3FromApp(awsApp, "localhost", "host-2")
require.NoError(t, err)
awsSrv2.SetComponentFeatures(componentfeatures.New(feature1, feature2))
_, err = env.server.Auth().UpsertApplicationServer(ctx, awsSrv2)
require.NoError(t, err)
clusterName := env.server.ClusterName()
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "resources")
query := url.Values{"kinds": []string{types.KindApp}}
require.Eventually(t, func() bool {
return env.server.Auth().UnifiedResourceCache.IsInitialized()
}, 5*time.Second, 50*time.Millisecond, "unified resource cache failed to initialize")
// Ensure aggregated AppServers' features are intersected as expected
require.EventuallyWithT(t, func(t *assert.CollectT) {
var got []types.AppServer
for srv, err := range env.server.Auth().UnifiedResourceCache.AppServers(
ctx, services.UnifiedResourcesIterateParams{},
) {
require.NoError(t, err)
if srv.GetApp() == nil || srv.GetApp().GetName() != appName {
continue
}
got = append(got, srv)
}
require.Len(t, got, 1)
cf := got[0].GetComponentFeatures()
require.NotNil(t, cf)
require.ElementsMatch(t, componentfeatures.New(feature1, feature2).GetFeatures(), cf.GetFeatures())
}, 5*time.Second, 50*time.Millisecond, "unified resource cache did not expose aggregated AppServer")
// Ensure returned UnifiedResource's features are intersected as expected (against all auth/proxy)
re, err := pack.clt.Get(ctx, endpoint, query)
require.NoError(t, err)
var resp struct {
Items []webui.App `json:"items"`
}
err = json.Unmarshal(re.Bytes(), &resp)
require.NoError(t, err)
require.Len(t, resp.Items, 1)
app := resp.Items[0]
require.True(t, app.AWSConsole)
require.ElementsMatch(t, []int{int(feature1)}, app.SupportedFeatureIDs)
}
func TestUnifiedResourcesGet(t *testing.T) {
t.Parallel()
env := newWebPack(t, 1)
+8
View File
@@ -24,7 +24,9 @@ import (
"log/slog"
"sort"
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/ui"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/aws"
@@ -81,6 +83,9 @@ type App struct {
// MCP includes MCP specific configuration.
MCP *MCP `json:"mcp,omitempty"`
// SupportedFeatureIDs contains ComponentFeatures supported by this App and all other involved components.
SupportedFeatureIDs []int `json:"supportedFeatureIds,omitempty"`
}
// UserGroupAndDescription is a user group name and its description.
@@ -135,6 +140,8 @@ type MakeAppsConfig struct {
Logger *slog.Logger
// RequireRequest indicates if a returned resource is only accessible after an access request
RequiresRequest bool
// SupportedFeatures contains ComponentFeatures supported by this App and all other involved components.
SupportedFeatures *componentfeaturesv1.ComponentFeatures
}
// MakeApp creates an application object for the WebUI.
@@ -187,6 +194,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App {
Integration: app.GetIntegration(),
PermissionSets: permissionSets,
UseAnyProxyPublicAddr: app.GetUseAnyProxyPublicAddr(),
SupportedFeatureIDs: componentfeatures.ToIntegers(c.SupportedFeatures),
}
if app.IsAWSConsole() {
+42
View File
@@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/ui"
)
@@ -41,6 +42,47 @@ func newApp(t *testing.T, name, publicAddr, description string, labels map[strin
return app
}
func TestMakeApp_SupportedFeatureIDs(t *testing.T) {
t.Parallel()
app, err := types.NewAppV3(
types.Metadata{Name: "test-app"},
types.AppSpecV3{URI: "localhost:8080"},
)
require.NoError(t, err)
baseCfg := MakeAppsConfig{
LocalClusterName: "root",
LocalProxyDNSName: "proxy.example.com",
AppClusterName: "root",
UserGroupLookup: map[string]types.UserGroup{},
}
t.Run("nil SupportedFeatures yields empty SupportedFeatureIDs", func(t *testing.T) {
t.Parallel()
cfg := baseCfg
cfg.SupportedFeatures = nil
out := MakeApp(app, cfg)
require.Empty(t, out.SupportedFeatureIDs)
})
t.Run("SupportedFeatures is converted to SupportedFeatureIDs", func(t *testing.T) {
t.Parallel()
f1 := componentfeatures.FeatureResourceConstraintsV1
f2 := componentfeatures.FeatureID(9999)
cfg := baseCfg
cfg.SupportedFeatures = componentfeatures.New(f1, f2)
out := MakeApp(app, cfg)
require.ElementsMatch(t, []int{int(f1), int(f2)}, out.SupportedFeatureIDs)
})
}
func TestMakeAppTypeFromSAMLApp(t *testing.T) {
tests := []struct {
name string
@@ -24,6 +24,7 @@ import { ResourceIconName } from 'design/ResourceIcon';
import { TargetHealth } from 'gen-proto-ts/teleport/lib/teleterm/v1/target_health_pb';
import { AppSubKind, NodeSubKind } from 'shared/services';
import { DbProtocol } from 'shared/services/databases';
import type { ComponentFeatureID } from 'shared/utils/componentFeatures';
// eslint-disable-next-line no-restricted-imports -- FIXME
import { ResourceLabel } from 'teleport/services/agents';
@@ -88,6 +89,7 @@ export type UnifiedResourceApp = {
subKind?: AppSubKind;
permissionSets?: PermissionSet[];
mcp?: AppMCP;
supportedFeatureIds?: ComponentFeatureID[];
};
export interface UnifiedResourceDatabase {
@@ -0,0 +1,33 @@
/**
* Teleport
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
*/
/**
* ComponentFeatureID is an identifier for a specific feature supported
* by a Teleport component.
*/
export enum ComponentFeatureID {
/**
* Unspecified is the unspecified feature ID.
*/
Unspecified = 0,
/**
* ResourceConstraintsV1 indicates support for Resource Constraints in
* Access Requests, identity certificates, and AWS Console App resources.
*/
ResourceConstraintsV1 = 1,
}