support where RBAC on application session recordings (#69630)

* support `where` RBAC on application session recordings

application session recordings are backed by AppSessionChunk events, but
those events were never wired into the session recording RBAC path, so
`where` clauses on `kind: session` rules could not match them.

Fixes https://github.com/gravitational/teleport/issues/67775

Signed-off-by: Tiago Silva <[email protected]>

* fix unit test

* handle feedback

---------

Signed-off-by: Tiago Silva <[email protected]>
This commit is contained in:
Tiago Silva
2026-08-13 14:29:20 +00:00
committed by GitHub
parent 6fc6dc0733
commit 05419f28b6
13 changed files with 1868 additions and 1592 deletions
@@ -3279,6 +3279,10 @@ message AppSessionChunk {
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// Participants is the list of participants in a session.
// App sessions only have one participant but we keep it a repeated field
// to keep compatibility with other session end events.
repeated string Participants = 8 [(gogoproto.jsontag) = "participants,omitempty"];
}
// AppSessionRequest is an HTTP request and response.
+2
View File
@@ -126,4 +126,6 @@ const (
EventProtocolTDP = "tdp"
// EventProtocolDB specifies database as a type of captured protocol
EventProtocolDB = "db"
// EventProtocolApp specifies app as a type of captured protocol
EventProtocolApp = "app"
)
+1633 -1582
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -595,9 +595,8 @@ resource type and the session creator (user who initiated the session) roles and
| Session Field | Description | Type |
|---|---|---|
| `session.id` | The unique identifier of the session. | `string` |
| `session.kind` | The kind of session (e.g., "ssh", "kube", "db", "rdp"). | `string` |
| `session.participants` | The list of users who participated in the session. | `array<string>` |
| `session.proto` | The proto used for the session (e.g., "ssh", "kube", "db", "rdp"). | `string` |
| `session.proto` | The proto used for the session (e.g., "ssh", "kube", "db", "tdp", "app"). | `string` |
| `session.user` | The user who initiated the session. | `string` |
| `session.user_roles` | The roles assigned to the user who initiated the session. | `array<string>` |
| `session.user_traits` | The traits assigned to the user who initiated the session. | `dict<string, array<string>>` |
@@ -669,6 +668,22 @@ for Windows Desktop sessions together with the user and session fields above.
| `session.windows_user` | The username used to log in to the Windows desktop during the session. | `string` |
| `session.desktop_labels` | The labels assigned to the Windows desktop where the session took place. | `dict<string, string>` |
</TabItem>
<TabItem value="Application Sessions" label="Application Sessions">
The following application session fields can be used in `where` expressions to filter access
for application sessions together with the user and session fields above.
| Application Session Field | Description | Type |
|---|---|---|
| `session.app_name` | The name of the application where the session took place. | `string` |
| `session.app_uri` | The endpoint of the application where the session took place. | `string` |
| `session.app_public_addr` | The public address of the application where the session took place. | `string` |
| `session.app_labels` | The labels assigned to the application where the session took place. | `dict<string, string>` |
Only HTTP/HTTPS applications produce session recordings, so these fields are not
available for TCP applications.
</TabItem>
</Tabs>
+2
View File
@@ -753,6 +753,7 @@ func testAuditEvents(p *Pack, t *testing.T) {
AppPublicAddr: p.rootAppPublicAddr,
AppName: p.rootAppName,
},
Participants: []string{p.username},
}
return len(cmp.Diff(
expectedEvent,
@@ -760,6 +761,7 @@ func testAuditEvents(p *Pack, t *testing.T) {
cmpopts.IgnoreTypes(apievents.ServerMetadata{}, apievents.SessionMetadata{}, apievents.UserMetadata{}, apievents.ConnectionMetadata{}),
cmpopts.IgnoreFields(apievents.Metadata{}, "ID", "Time", "Index"),
cmpopts.IgnoreFields(apievents.AppSessionChunk{}, "SessionChunkID"),
cmpopts.IgnoreFields(apievents.AppMetadata{}, "AppLabels"),
)) == 0
})
}
+4 -4
View File
@@ -843,10 +843,10 @@ func (p *Pack) startRootAppServers(t *testing.T, count int, opts AppTestOptions)
URI: p.rootTCPMultiPortAppURI,
PublicAddr: p.rootTCPMultiPortPublicAddr,
TCPPorts: []servicecfg.PortRange{
servicecfg.PortRange{
{
Port: p.rootTCPMultiPortAppPortAlpha,
},
servicecfg.PortRange{
{
Port: p.rootTCPMultiPortAppPortBeta,
},
},
@@ -1008,10 +1008,10 @@ func (p *Pack) startLeafAppServers(t *testing.T, count int, opts AppTestOptions)
URI: p.leafTCPMultiPortAppURI,
PublicAddr: p.leafTCPMultiPortPublicAddr,
TCPPorts: []servicecfg.PortRange{
servicecfg.PortRange{
{
Port: p.leafTCPMultiPortAppPortAlpha,
},
servicecfg.PortRange{
{
Port: p.leafTCPMultiPortAppPortBeta,
},
},
+2
View File
@@ -60,6 +60,8 @@ const (
EventProtocolTDP = apievents.EventProtocolTDP
// EventProtocolDB specifies database as a type of captured protocol
EventProtocolDB = apievents.EventProtocolDB
// EventProtocolApp specifies app as a type of captured protocol
EventProtocolApp = apievents.EventProtocolApp
// LocalAddr is a target address on the host
LocalAddr = "addr.local"
// RemoteAddr is a client (user's) address
+2 -2
View File
@@ -480,7 +480,7 @@ func (ctx *Context) GetIdentifier(fields []string) (any, error) {
case SessionIdentifier:
var session events.AuditEvent = &events.SessionEnd{}
switch ctx.Session.(type) {
case *events.SessionEnd, *events.WindowsDesktopSessionEnd, *events.LinuxDesktopSessionEnd, *events.DatabaseSessionEnd:
case *events.SessionEnd, *events.WindowsDesktopSessionEnd, *events.LinuxDesktopSessionEnd, *events.DatabaseSessionEnd, *events.AppSessionChunk:
session = ctx.Session
}
v, origErr := predicate.GetFieldByTag(session, teleport.JSON, fields[1:])
@@ -537,7 +537,7 @@ func (ctx *Context) GetIdentifier(fields []string) (any, error) {
}
func getMissingEmptyFieldForSessionEnd(fields []string) (any, error) {
for _, emptySession := range []events.AuditEvent{&events.SessionEnd{}, &events.WindowsDesktopSessionEnd{}, &events.DatabaseSessionEnd{}} {
for _, emptySession := range []events.AuditEvent{&events.SessionEnd{}, &events.WindowsDesktopSessionEnd{}, &events.DatabaseSessionEnd{}, &events.AppSessionChunk{}} {
v, err := predicate.GetFieldByTag(emptySession, teleport.JSON, fields[1:])
if err == nil {
return v, nil
+117
View File
@@ -11245,6 +11245,9 @@ func TestSessionRecordingRBAC(t *testing.T) {
NodeLabels: types.Labels{
"env": []string{"prod"},
},
AppLabels: types.Labels{
"env": []string{"prod"},
},
Rules: allowRules,
},
Deny: types.RoleConditions{
@@ -11270,6 +11273,25 @@ func TestSessionRecordingRBAC(t *testing.T) {
"env": "dev",
})
newApp := func(name string, labels map[string]string) types.Application {
app, err := types.NewAppV3(types.Metadata{
Name: name,
Labels: labels,
}, types.AppSpecV3{
URI: "http://localhost:8080",
})
require.NoError(t, err)
return app
}
appWithAccess := newApp("app-with-access", map[string]string{
"env": "prod",
})
appWithoutAccess := newApp("app-without-access", map[string]string{
"env": "dev",
})
alice := newUser(
"alice",
map[string][]string{
@@ -11764,6 +11786,70 @@ func TestSessionRecordingRBAC(t *testing.T) {
},
errCheck: require.NoError,
},
{
name: "24 - bob can read app session because his group matches with env label for app",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.app_labels["env"])`,
}, nil),
},
context: Context{
User: bob,
Session: newAppSessionChunkEvent(),
Resource: appWithAccess,
},
errCheck: require.NoError,
},
{
name: "25 - john can not read app session because his group does not match with env label for app",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.app_labels["env"])`,
}, nil),
},
context: Context{
User: john,
Session: newAppSessionChunkEvent(),
Resource: appWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "26 - bob can read app session because he has access to the app",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
context: Context{
User: bob,
Session: newAppSessionChunkEvent(),
Resource: appWithAccess,
},
errCheck: require.NoError,
},
{
name: "27 - bob can not read app session because he has no access to the app",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
context: Context{
User: bob,
Session: newAppSessionChunkEvent(),
Resource: appWithoutAccess,
},
errCheck: requireAccessDenied,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
@@ -11865,6 +11951,37 @@ func newDatabaseSessionEndEvent() *apievents.DatabaseSessionEnd {
}
}
func newAppSessionChunkEvent() *apievents.AppSessionChunk {
return &apievents.AppSessionChunk{
Metadata: apievents.Metadata{
Index: 20,
Type: "app.session.chunk",
ID: "da455e0f-c27d-459f-a218-4e83b3db9426",
Code: "T2008I",
Time: time.Date(2020, 3, 30, 15, 58, 54, 561*int(time.Millisecond), time.UTC),
},
AppMetadata: apievents.AppMetadata{
AppName: "app-with-access",
AppURI: "http://localhost:8080",
AppPublicAddr: "app.example.com",
AppLabels: map[string]string{"env": "prod"},
},
ConnectionMetadata: apievents.ConnectionMetadata{
Protocol: apievents.EventProtocolApp,
},
UserMetadata: apievents.UserMetadata{
User: "alice",
UserRoles: []string{"role1"},
UserTraits: map[string][]string{
"group": {"prod"},
"team": {"engineering"},
},
},
Participants: []string{"alice"},
SessionChunkID: "cb116fb0-9227-4889-9392-aedd13a914a6",
}
}
func TestCheckSubmitForUser(t *testing.T) {
t.Parallel()
+17
View File
@@ -106,6 +106,23 @@ func rebuildResourceFromSessionEndEvent(event apievents.AuditEvent) types.Resour
Domain: sEnd.Domain,
},
}
case *apievents.AppSessionChunk:
if sEnd == nil {
return nil
}
return &types.AppV3{
Kind: types.KindApp,
Version: types.V3,
Metadata: types.Metadata{
Name: sEnd.AppName,
Namespace: apidefaults.Namespace,
Labels: sEnd.AppLabels,
},
Spec: types.AppSpecV3{
URI: sEnd.AppURI,
PublicAddr: sEnd.AppPublicAddr,
},
}
case *apievents.DatabaseSessionEnd:
if sEnd == nil {
return nil
+35
View File
@@ -53,6 +53,11 @@ func TestRebuildResourceFromSessionEndEvent(t *testing.T) {
event: (*apievents.DatabaseSessionEnd)(nil),
want: nil,
},
{
name: "nil with type set app.session.chunk",
event: (*apievents.AppSessionChunk)(nil),
want: nil,
},
{
name: "non session.end event",
event: &apievents.UserLogin{
@@ -188,6 +193,36 @@ func TestRebuildResourceFromSessionEndEvent(t *testing.T) {
Spec: types.DatabaseSpecV3{},
},
},
{
name: "app session chunk",
event: &apievents.AppSessionChunk{
AppMetadata: apievents.AppMetadata{
AppName: "app-name",
AppURI: "http://localhost:8080",
AppPublicAddr: "app.example.com",
AppLabels: map[string]string{
"env": "prod",
"team": "web",
},
},
},
want: &types.AppV3{
Kind: types.KindApp,
Version: types.V3,
Metadata: types.Metadata{
Name: "app-name",
Namespace: "default",
Labels: map[string]string{
"env": "prod",
"team": "web",
},
},
Spec: types.AppSpecV3{
URI: "http://localhost:8080",
PublicAddr: "app.example.com",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+23
View File
@@ -29,7 +29,9 @@ import (
apidefaults "github.com/gravitational/teleport/api/defaults"
"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"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/tlsca"
awsutils "github.com/gravitational/teleport/lib/utils/aws"
)
@@ -156,6 +158,14 @@ func (a *audit) OnSessionEnd(ctx context.Context, serverID string, identity *tls
// OnSessionChunk is called when a new session chunk is created.
func (a *audit) OnSessionChunk(ctx context.Context, serverID, chunkID string, identity *tlsca.Identity, app types.Application) error {
// Enforce IP Pinning if it is present in the user's certificate.
var clientAddr string
if clientSrcAddr, err := authz.ClientSrcAddrFromContext(ctx); err == nil {
clientAddr = clientSrcAddr.String()
} else {
clientAddr = identity.LoginIP
}
event := &apievents.AppSessionChunk{
Metadata: apievents.Metadata{
Type: events.AppSessionChunkEvent,
@@ -167,15 +177,28 @@ func (a *audit) OnSessionChunk(ctx context.Context, serverID, chunkID string, id
ServerID: serverID,
ServerNamespace: apidefaults.Namespace,
},
ConnectionMetadata: apievents.ConnectionMetadata{
LocalAddr: clientAddr,
RemoteAddr: app.GetURI(),
Protocol: events.EventProtocolApp,
},
SessionMetadata: getSessionMetadata(identity),
UserMetadata: identity.GetUserMetadata(),
AppMetadata: apievents.AppMetadata{
AppURI: app.GetURI(),
AppPublicAddr: app.GetPublicAddr(),
AppName: app.GetName(),
AppLabels: app.GetAllLabels(),
// Session chunks are not created for TCP apps, so there's no need to pass TargetPort here.
},
SessionChunkID: chunkID,
Participants: []string{services.UsernameForCluster(
services.UsernameForClusterConfig{
User: identity.Username,
OriginClusterName: identity.OriginClusterName,
LocalClusterName: identity.TeleportCluster,
},
)},
}
return trace.Wrap(a.EmitEvent(ctx, event))
}
+10 -2
View File
@@ -644,7 +644,6 @@ func TestShutdown(t *testing.T) {
return
}
}, 10*time.Second, 100*time.Millisecond)
}
})
}
@@ -1037,7 +1036,6 @@ func TestRewriteJWT(t *testing.T) {
})
})
}
}
// TestAuthorize verifies that only authorized requests are handled.
@@ -1293,7 +1291,12 @@ func TestRequestAuditEvents(t *testing.T) {
AppURI: app.Spec.URI,
AppPublicAddr: app.Spec.PublicAddr,
AppName: app.Metadata.Name,
AppLabels: map[string]string{
"bar": "baz",
"qux": "4",
},
},
Participants: []string{"foo"},
}
require.Empty(t, gocmp.Diff(
expectedEvent,
@@ -1370,7 +1373,12 @@ func TestRequestAuditEvents(t *testing.T) {
AppURI: app.Spec.URI,
AppPublicAddr: app.Spec.PublicAddr,
AppName: app.Metadata.Name,
AppLabels: map[string]string{
"bar": "baz",
"qux": "4",
},
},
Participants: []string{"foo"},
}
require.Empty(t, gocmp.Diff(
expectedEvent,