fix: Add SSH ResourceConstraint event messages, fix unmarshalling (#65721)

This commit is contained in:
Maxim
2026-05-01 21:13:23 +00:00
committed by GitHub
parent 64aff0040c
commit 2e2fdfe041
8 changed files with 2191 additions and 1536 deletions
@@ -1723,11 +1723,13 @@ message ResourceAccessID {
ResourceID id = 1 [(gogoproto.nullable) = false];
oneof constraints {
// unknown_constraints represents that constraints were present on the internal ResourceAccessID,
// but are of an unknown type.
// unknown_constraints represents that constraints were present on the internal
// ResourceAccessID, but are of an unknown or unsupported type.
UnknownConstraints unknown_constraints = 10;
// aws_console captures audit-log–friendly data for AWS Console scoping constraints.
AWSConsoleConstraints aws_console = 11;
// ssh captures audit-log-friendly data for SSH node scoping constraints.
SSHConstraints ssh = 12;
}
}
@@ -1747,6 +1749,14 @@ message AWSConsoleConstraints {
repeated string role_arns_preview = 2;
}
// SSHConstraints is an audit-log-friendly view of SSH node constraints.
message SSHConstraints {
// logins_count is the total number of logins requested.
uint32 logins_count = 1;
// logins_preview contains up to N logins for inspection; it may be truncated.
repeated string logins_preview = 2;
}
// AccessRequestDelete is emitted when an access request has been deleted.
message AccessRequestDelete {
// Metadata is a common event metadata
+1816 -1523
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
/*
Copyright 2026 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.
*/
package events
import "encoding/json"
// constraintsJSON is the JSON-serialisable form of the protobuf oneof
// constraints field in [ResourceAccessID]. Each variant maps to one of the
// concrete oneof wrapper types.
type constraintsJSON struct {
UnknownConstraints *UnknownConstraints `json:"unknown_constraints,omitempty"`
AWSConsole *AWSConsoleConstraints `json:"aws_console,omitempty"`
SSH *SSHConstraints `json:"ssh,omitempty"`
}
// resourceAccessIDJSON mirrors [ResourceAccessID] with a concrete Constraints
// field so that encoding/json can marshal and unmarshal the value without
// needing to know the concrete interface type at runtime.
type resourceAccessIDJSON struct {
Id ResourceID `json:"id"`
Constraints *constraintsJSON `json:"constraints,omitempty"`
}
// MarshalJSON implements [json.Marshaler] for [ResourceAccessID].
func (r *ResourceAccessID) MarshalJSON() ([]byte, error) {
out := resourceAccessIDJSON{Id: r.Id}
switch c := r.Constraints.(type) {
case *ResourceAccessID_UnknownConstraints:
out.Constraints = &constraintsJSON{UnknownConstraints: c.UnknownConstraints}
case *ResourceAccessID_AwsConsole:
out.Constraints = &constraintsJSON{AWSConsole: c.AwsConsole}
case *ResourceAccessID_Ssh:
out.Constraints = &constraintsJSON{SSH: c.Ssh}
}
return json.Marshal(out)
}
// UnmarshalJSON implements [json.Unmarshaler] for [ResourceAccessID].
func (r *ResourceAccessID) UnmarshalJSON(data []byte) error {
var tmp resourceAccessIDJSON
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
r.Id = tmp.Id
if tmp.Constraints == nil {
return nil
}
switch {
case tmp.Constraints.UnknownConstraints != nil:
r.Constraints = &ResourceAccessID_UnknownConstraints{UnknownConstraints: tmp.Constraints.UnknownConstraints}
case tmp.Constraints.AWSConsole != nil:
r.Constraints = &ResourceAccessID_AwsConsole{AwsConsole: tmp.Constraints.AWSConsole}
case tmp.Constraints.SSH != nil:
r.Constraints = &ResourceAccessID_Ssh{Ssh: tmp.Constraints.SSH}
}
return nil
}
+15 -9
View File
@@ -67,29 +67,35 @@ func ToEventResourceAccessID(in types.ResourceAccessID) ResourceAccessID {
details := c.GetDetails()
if details == nil {
out.Constraints = &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}}
return out
}
switch d := details.(type) {
// AWS Console constraints variant
case *types.ResourceConstraints_AwsConsole:
if d.AwsConsole == nil {
// If payload is missing treat as unknown/unsupported
out.Constraints = &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}}
break
}
roleARNs := d.AwsConsole.RoleArns
count := len(roleARNs)
preview := previewStrings(roleARNs, MaxAuditRoleARNPreview)
out.Constraints = &ResourceAccessID_AwsConsole{
AwsConsole: &AWSConsoleConstraints{
RoleArnsCount: uint32(count),
RoleArnsPreview: preview,
RoleArnsCount: uint32(len(roleARNs)),
RoleArnsPreview: previewStrings(roleARNs, MaxAuditRoleARNPreview),
},
}
case *types.ResourceConstraints_Ssh:
if d.Ssh == nil {
out.Constraints = &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}}
break
}
logins := d.Ssh.Logins
out.Constraints = &ResourceAccessID_Ssh{
Ssh: &SSHConstraints{
LoginsCount: uint32(len(logins)),
LoginsPreview: previewStrings(logins, MaxAuditRoleARNPreview),
},
}
// Unknown/unsupported constraint variant
default:
out.Constraints = &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}}
}
+193
View File
@@ -0,0 +1,193 @@
/*
Copyright 2026 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.
*/
package events
import (
"encoding/json"
"slices"
"testing"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
)
func TestToEventResourceAccessID(t *testing.T) {
id := types.ResourceID{ClusterName: "example.teleport.sh", Kind: "node", Name: "server-01"}
eventID := ResourceID{ClusterName: "example.teleport.sh", Kind: "node", Name: "server-01"}
tests := []struct {
name string
in types.ResourceAccessID
wantConstraints isResourceAccessID_Constraints
}{
{
name: "no constraints",
in: types.ResourceAccessID{Id: id},
wantConstraints: nil,
},
{
name: "aws console constraints",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{
Details: &types.ResourceConstraints_AwsConsole{
AwsConsole: &types.AWSConsoleResourceConstraints{
RoleArns: []string{"arn:aws:iam::123:role/A", "arn:aws:iam::123:role/B"},
},
},
},
},
wantConstraints: &ResourceAccessID_AwsConsole{
AwsConsole: &AWSConsoleConstraints{
RoleArnsCount: 2,
RoleArnsPreview: []string{"arn:aws:iam::123:role/A", "arn:aws:iam::123:role/B"},
},
},
},
{
name: "aws console constraints truncated to preview limit",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{
Details: &types.ResourceConstraints_AwsConsole{
AwsConsole: &types.AWSConsoleResourceConstraints{
RoleArns: slices.Repeat([]string{"arn:aws:iam::123456789012:role/Role"}, MaxAuditRoleARNPreview+5),
},
},
},
},
wantConstraints: &ResourceAccessID_AwsConsole{
AwsConsole: &AWSConsoleConstraints{
RoleArnsCount: MaxAuditRoleARNPreview + 5,
RoleArnsPreview: slices.Repeat([]string{"arn:aws:iam::123456789012:role/Role"}, MaxAuditRoleARNPreview),
},
},
},
{
name: "ssh constraints",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{
Details: &types.ResourceConstraints_Ssh{
Ssh: &types.SSHResourceConstraints{
Logins: []string{"alice", "root"},
},
},
},
},
wantConstraints: &ResourceAccessID_Ssh{
Ssh: &SSHConstraints{LoginsCount: 2, LoginsPreview: []string{"alice", "root"}},
},
},
{
name: "unknown constraint type falls back to unknown_constraints",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{},
},
wantConstraints: &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}},
},
{
name: "aws console with nil payload falls back to unknown_constraints",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{
Details: &types.ResourceConstraints_AwsConsole{AwsConsole: nil},
},
},
wantConstraints: &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}},
},
{
name: "ssh with nil payload falls back to unknown_constraints",
in: types.ResourceAccessID{
Id: id,
Constraints: &types.ResourceConstraints{
Details: &types.ResourceConstraints_Ssh{Ssh: nil},
},
},
wantConstraints: &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToEventResourceAccessID(tt.in)
require.Equal(t, eventID, got.Id)
require.Equal(t, tt.wantConstraints, got.Constraints)
})
}
}
// TestResourceAccessIDJSONRoundTrip is the regression test for the audit-log
// read path. Events are stored as JSON in the file log and decoded via
// FastUnmarshal on read. The protobuf one of generates an interface field
// (isResourceAccessID_Constraints) that encoding/json cannot unmarshal into.
// MarshalJSON/UnmarshalJSON on ResourceAccessID fix this by using an intermediate struct.
func TestResourceAccessIDJSONRoundTrip(t *testing.T) {
id := ResourceID{ClusterName: "example.teleport.sh", Kind: "node", Name: "server-01"}
tests := []struct {
name string
in ResourceAccessID
}{
{
name: "no constraints",
in: ResourceAccessID{Id: id},
},
{
name: "unknown constraints",
in: ResourceAccessID{
Id: id,
Constraints: &ResourceAccessID_UnknownConstraints{UnknownConstraints: &UnknownConstraints{}},
},
},
{
name: "aws console constraints",
in: ResourceAccessID{
Id: id,
Constraints: &ResourceAccessID_AwsConsole{
AwsConsole: &AWSConsoleConstraints{
RoleArnsCount: 3,
RoleArnsPreview: []string{"arn:aws:iam::123:role/A", "arn:aws:iam::123:role/B"},
},
},
},
},
{
name: "ssh constraints",
in: ResourceAccessID{
Id: id,
Constraints: &ResourceAccessID_Ssh{
Ssh: &SSHConstraints{LoginsCount: 2, LoginsPreview: []string{"alice", "root"}}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(&tt.in)
require.NoError(t, err)
var got ResourceAccessID
require.NoError(t, json.Unmarshal(data, &got))
require.Equal(t, tt.in.Id, got.Id)
require.Equal(t, tt.in.Constraints, got.Constraints)
})
}
}