Skip to content

Commit 7f6571d

Browse files
authored
[v16] Workload ID: Podman Workload Attestation (#52980)
Backport #52192 to v16
1 parent 9da533d commit 7f6571d

32 files changed

+1801
-234
lines changed

api/gen/proto/go/teleport/workloadidentity/v1/attrs.pb.go

+377-84
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/proto/teleport/workloadidentity/v1/attrs.proto

+30
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ message WorkloadAttrsUnix {
4949
uint32 uid = 4;
5050
}
5151

52+
// Attributes sourced from the Podman workload attestor.
53+
message WorkloadAttrsPodman {
54+
// Whether the workload passed Podman attestation.
55+
bool attested = 1;
56+
// Attributes of the container.
57+
WorkloadAttrsPodmanContainer container = 2;
58+
// Attributes of the pod, if the container is in one.
59+
optional WorkloadAttrsPodmanPod pod = 3;
60+
}
61+
62+
// Attributes of the container sourced from the Podman workload attestation.
63+
message WorkloadAttrsPodmanContainer {
64+
// The name of the container.
65+
string name = 1;
66+
// The image the container is running.
67+
string image = 2;
68+
// The labels attached to the container.
69+
map<string, string> labels = 3;
70+
}
71+
72+
// Attributes of the pod sourced from the Podman workload attestation.
73+
message WorkloadAttrsPodmanPod {
74+
// The name of the pod.
75+
string name = 1;
76+
// The labels attached to the pod.
77+
map<string, string> labels = 2;
78+
}
79+
5280
// The attributes provided by `tbot` regarding the workload's attestation.
5381
// This will be mostly unset if the workload has not requested credentials via
5482
// the SPIFFE Workload API.
@@ -57,6 +85,8 @@ message WorkloadAttrs {
5785
WorkloadAttrsUnix unix = 1;
5886
// The Kubernetes-specific attributes.
5987
WorkloadAttrsKubernetes kubernetes = 2;
88+
// The Podman-specific attributes.
89+
WorkloadAttrsPodman podman = 3;
6090
}
6191

6292
// Attributes related to the user/bot making the request for a workload

lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden

+4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ services:
4242
attestors:
4343
kubernetes:
4444
enabled: false
45+
podman:
46+
enabled: false
4547
- type: example
4648
message: llama
4749
- type: ssh-multiplexer
@@ -66,6 +68,8 @@ services:
6668
attestors:
6769
kubernetes:
6870
enabled: false
71+
podman:
72+
enabled: false
6973
selector:
7074
name: my-workload-identity
7175
- type: workload-identity-jwt

lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden

+2
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ attestors:
3030
ca_path: /path/to/ca.pem
3131
skip_verify: true
3232
anonymous: true
33+
podman:
34+
enabled: false
3335
jwt_svid_ttl: 5m0s

lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden

+2
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ svids:
55
attestors:
66
kubernetes:
77
enabled: false
8+
podman:
9+
enabled: false

lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden

+2
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ attestors:
99
ca_path: /path/to/ca.pem
1010
skip_verify: true
1111
anonymous: true
12+
podman:
13+
enabled: false
1214
selector:
1315
name: my-workload-identity

lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden

+2
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ listen: tcp://0.0.0.0:4040
33
attestors:
44
kubernetes:
55
enabled: false
6+
podman:
7+
enabled: false
68
selector:
79
name: my-workload-identity

lib/tbot/workloadidentity/workloadattest/attest.go

+18-1
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,24 @@ type attestor[T any] interface {
3636
type Attestor struct {
3737
log *slog.Logger
3838
kubernetes attestor[*workloadidentityv1pb.WorkloadAttrsKubernetes]
39+
podman attestor[*workloadidentityv1pb.WorkloadAttrsPodman]
3940
unix attestor[*workloadidentityv1pb.WorkloadAttrsUnix]
4041
}
4142

4243
// Config is the configuration for Attestor
4344
type Config struct {
4445
Kubernetes KubernetesAttestorConfig `yaml:"kubernetes"`
46+
Podman PodmanAttestorConfig `yaml:"podman"`
4547
}
4648

4749
func (c *Config) CheckAndSetDefaults() error {
48-
return trace.Wrap(c.Kubernetes.CheckAndSetDefaults(), "validating kubernetes")
50+
if err := c.Kubernetes.CheckAndSetDefaults(); err != nil {
51+
return trace.Wrap(err, "validating kubernetes")
52+
}
53+
if err := c.Podman.CheckAndSetDefaults(); err != nil {
54+
return trace.Wrap(err, "validating podman")
55+
}
56+
return nil
4957
}
5058

5159
// NewAttestor returns an Attestor from the given config.
@@ -57,6 +65,9 @@ func NewAttestor(log *slog.Logger, cfg Config) (*Attestor, error) {
5765
if cfg.Kubernetes.Enabled {
5866
att.kubernetes = NewKubernetesAttestor(cfg.Kubernetes, log)
5967
}
68+
if cfg.Podman.Enabled {
69+
att.podman = NewPodmanAttestor(cfg.Podman, log)
70+
}
6071
return att, nil
6172
}
6273

@@ -81,6 +92,12 @@ func (a *Attestor) Attest(ctx context.Context, pid int) (*workloadidentityv1pb.W
8192
a.log.WarnContext(ctx, "Failed to perform Kubernetes workload attestation", "error", err)
8293
}
8394
}
95+
if a.podman != nil {
96+
attrs.Podman, err = a.podman.Attest(ctx, pid)
97+
if err != nil {
98+
a.log.WarnContext(ctx, "Failed to perform Podman workload attestation", "error", err)
99+
}
100+
}
84101

85102
return attrs, nil
86103
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package container
20+
21+
import (
22+
"path"
23+
"strconv"
24+
25+
"github.com/gravitational/trace"
26+
"k8s.io/utils/mount"
27+
)
28+
29+
// Rootfulness describes whether a container was started by an unprivileged user
30+
// as in "rootless" Podman or by root. It's a best guess based on information
31+
// gleaned from procfs, not authoratative.
32+
type Rootfulness int
33+
34+
const (
35+
// RootfulnessUnknown means we were unable to infer whether the container is
36+
// rootless or not.
37+
RootfulnessUnknown Rootfulness = iota
38+
39+
// Rootful means the container was probably started by root.
40+
Rootful
41+
42+
// Rootless means the container was probably started by an unprivileged user.
43+
Rootless
44+
)
45+
46+
// Info holds the information discovered about the container.
47+
type Info struct {
48+
// ID is the container's ID.
49+
ID string
50+
51+
// PodID identifies to which "pod" the container belongs, in engines that
52+
// support pods such as Kubernetes and Podman.
53+
PodID string
54+
55+
// Rootfulness describes whether a container was started by an unprivileged
56+
// user as in "rootless" Podman or by root. It's a best guess based on
57+
// information gleaned from procfs, not authoratative.
58+
Rootfulness Rootfulness
59+
}
60+
61+
// Parser parses the cgroup mount path to extract the container and pod IDs.
62+
//
63+
// This information is encoded differently by the container runtimes.
64+
type Parser func(mountPath string) (*Info, error)
65+
66+
// LookupPID discovers information about the container in which the process with
67+
// the given PID is running, by interrogating procfs.
68+
//
69+
// rootPath allows you to optionally override the system root path in tests,
70+
// pass an empty string to use the real root.
71+
func LookupPID(rootPath string, pid int, parser Parser) (*Info, error) {
72+
info, err := mount.ParseMountInfo(
73+
path.Join(rootPath, "/proc", strconv.Itoa(pid), "mountinfo"),
74+
)
75+
if err != nil {
76+
return nil, trace.Wrap(err, "parsing mountinfo")
77+
}
78+
79+
// Find the cgroup or cgroupv2 mount.
80+
//
81+
// For cgroup v2, we expect a single mount. But for cgroup v1, there will
82+
// be one mount per subsystem, but regardless, they will all contain the
83+
// same container ID/pod ID.
84+
var cgroupMount mount.MountInfo
85+
for _, m := range info {
86+
if m.FsType == "cgroup" || m.FsType == "cgroup2" {
87+
cgroupMount = m
88+
break
89+
}
90+
}
91+
92+
ids, err := parser(cgroupMount.Root)
93+
if err != nil {
94+
return nil, trace.Wrap(
95+
err, "parsing cgroup mount (root: %q)", cgroupMount.Root,
96+
)
97+
}
98+
return ids, nil
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package container_test
20+
21+
import (
22+
"os"
23+
"path/filepath"
24+
"testing"
25+
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
29+
"github.com/gravitational/teleport/lib/tbot/workloadidentity/workloadattest/container"
30+
"github.com/gravitational/teleport/lib/utils"
31+
)
32+
33+
func TestLookupPID(t *testing.T) {
34+
tests := map[string]struct {
35+
parser container.Parser
36+
expected *container.Info
37+
error string
38+
}{
39+
"k8s-real-docker-desktop": {
40+
parser: container.KubernetesParser,
41+
expected: &container.Info{
42+
PodID: "941f292f-a62d-48ab-b9a8-eec84d87b928",
43+
ID: "3f79e718744418736d0f6b9958e08d44e969c6577068c33de1cc400d35aacec8",
44+
Rootfulness: container.RootfulnessUnknown,
45+
},
46+
},
47+
"k8s-real-orbstack": {
48+
parser: container.KubernetesParser,
49+
expected: &container.Info{
50+
PodID: "36827f77-691f-45aa-a470-0989cf3749c4",
51+
ID: "64dd9bf5199ff782835247cb072e4842dc3d0135ef02f6498cb6bb6f37a320d2",
52+
Rootfulness: container.RootfulnessUnknown,
53+
},
54+
},
55+
"k8s-real-k3s-ubuntu-v1.28.6+k3s2": {
56+
parser: container.KubernetesParser,
57+
expected: &container.Info{
58+
PodID: "fecd2321-17b5-49b9-9f75-8c5be777fbfb",
59+
ID: "397529d07efebd566f15dbc7e8af9f3ef586033f5e753adfa96b2bf730102c64",
60+
Rootfulness: container.RootfulnessUnknown,
61+
},
62+
},
63+
"k8s-real-gcp-v1.29.5-gke.1091002": {
64+
parser: container.KubernetesParser,
65+
expected: &container.Info{
66+
PodID: "61c266b0-6f75-4490-8d92-3c9ae4d02787",
67+
ID: "9da25af0b548c8c60aa60f77f299ba727bf72d58248bd7528eb5390ffcce555a",
68+
Rootfulness: container.RootfulnessUnknown,
69+
},
70+
},
71+
"podman-real-4.3.1-rootful-systemd-pod": {
72+
parser: container.PodmanParser,
73+
expected: &container.Info{
74+
PodID: "88c57f699ea2c137d7f19b7a6aaa5828072cf12207b56d7155f02d4ecade4510",
75+
ID: "4f6f96595778a052ebbd8e783156e347143cd79f81348d0995a0ffd5718c3393",
76+
Rootfulness: container.Rootful,
77+
},
78+
},
79+
"podman-real-4.3.1-rootful-systemd-container": {
80+
parser: container.PodmanParser,
81+
expected: &container.Info{
82+
PodID: "",
83+
ID: "12519ca1a57b8f58bc2a44f4e33e37eaf07c55a8d468ffb3db33f29d8d869186",
84+
Rootfulness: container.Rootful,
85+
},
86+
},
87+
"podman-real-4.3.1-rootless-systemd-pod": {
88+
parser: container.PodmanParser,
89+
expected: &container.Info{
90+
PodID: "5ffc3df0af9a6dd0f92668fc949734aad2ad41a5670b7218196d377d55ca32c5",
91+
ID: "d54768c18894b931db6f6876f6be2178d8a8b34fc3485659fda78fe86af3e08b",
92+
Rootfulness: container.Rootless,
93+
},
94+
},
95+
"podman-real-4.3.1-rootless-systemd-container": {
96+
parser: container.PodmanParser,
97+
expected: &container.Info{
98+
PodID: "",
99+
ID: "f89494c4c00e68029e176eb60c5be675f9b076b9ca63190678b27a2ef0d09d13",
100+
Rootfulness: container.Rootless,
101+
},
102+
},
103+
"podman-real-4.3.1-rootful-cgroupfs-container": {
104+
parser: container.PodmanParser,
105+
expected: &container.Info{
106+
PodID: "",
107+
ID: "1861a57278895fe0165c953c04e6c1082bcd73428776f5209616061d0022e881",
108+
Rootfulness: container.Rootful,
109+
},
110+
},
111+
"podman-real-4.3.1-rootless-cgroupfs-systemd-enabled-container": {
112+
parser: container.PodmanParser,
113+
error: "--cgroup-manager cgroupfs",
114+
},
115+
}
116+
for name, tc := range tests {
117+
t.Run(name, func(t *testing.T) {
118+
tempDir := t.TempDir()
119+
require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "proc", "1234"), 0755))
120+
require.NoError(t, utils.CopyFile(
121+
filepath.Join("testdata", "mountfile", name),
122+
filepath.Join(tempDir, "proc", "1234", "mountinfo"),
123+
0755),
124+
)
125+
126+
info, err := container.LookupPID(tempDir, 1234, tc.parser)
127+
if tc.error != "" {
128+
require.ErrorContains(t, err, tc.error)
129+
} else {
130+
require.NoError(t, err)
131+
assert.Equal(t, tc.expected, info)
132+
}
133+
})
134+
}
135+
}

0 commit comments

Comments
 (0)