diff --git a/central/auth/m2m/claims.go b/central/auth/m2m/claims.go
index 93fd3c8ccad26..899dbeb1b9c72 100644
--- a/central/auth/m2m/claims.go
+++ b/central/auth/m2m/claims.go
@@ -3,7 +3,6 @@ package m2m
import (
"fmt"
- "github.com/coreos/go-oidc/v3/oidc"
"github.com/pkg/errors"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/auth/tokens"
@@ -17,11 +16,14 @@ var (
)
type claimExtractor interface {
- ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error)
+ ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error)
}
func newClaimExtractorFromConfig(config *storage.AuthMachineToMachineConfig) claimExtractor {
- if config.GetType() == storage.AuthMachineToMachineConfig_GENERIC {
+ switch config.GetType() {
+ case storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW:
+ fallthrough
+ case storage.AuthMachineToMachineConfig_GENERIC:
return &genericClaimExtractor{configID: config.GetId()}
}
@@ -32,7 +34,7 @@ type genericClaimExtractor struct {
configID string
}
-func (g *genericClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
+func (g *genericClaimExtractor) ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error) {
var unstructured map[string]interface{}
if err := idToken.Claims(&unstructured); err != nil {
return tokens.RoxClaims{}, errors.Wrap(err, "extracting claims")
@@ -109,7 +111,7 @@ type githubActionClaims struct {
WorkflowSHA string `json:"workflow_sha"`
}
-func (g *githubClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
+func (g *githubClaimExtractor) ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error) {
// OIDC tokens issued for GitHub Actions have special claims, we'll reuse them.
var claims githubActionClaims
if err := idToken.Claims(&claims); err != nil {
diff --git a/central/auth/m2m/exchanger.go b/central/auth/m2m/exchanger.go
index a8e79b1e60460..422a1b3d89626 100644
--- a/central/auth/m2m/exchanger.go
+++ b/central/auth/m2m/exchanger.go
@@ -133,6 +133,12 @@ func mapToStringClaims(claims map[string]interface{}) map[string][]string {
stringClaims[key] = []string{value}
case []string:
stringClaims[key] = value
+ case []any:
+ for _, v := range value {
+ if s, ok := v.(string); ok {
+ stringClaims[key] = append(stringClaims[key], s)
+ }
+ }
default:
log.Debugf("Dropping value %v for claim %s since its a nested claim or a non-string type %T", value, key, value)
}
diff --git a/central/auth/m2m/exchanger_test.go b/central/auth/m2m/exchanger_test.go
index 618be8cf9feb0..7633f891f36b0 100644
--- a/central/auth/m2m/exchanger_test.go
+++ b/central/auth/m2m/exchanger_test.go
@@ -22,10 +22,12 @@ func TestMapStringToClaims(t *testing.T) {
[]string{"four", "five", "six"},
},
},
+ "groups": []any{"group1", "group2"},
}
expectedResult := map[string][]string{
- "sub": {"my-subject"},
- "aud": {"audience-1", "audience-2", "audience-3"},
+ "sub": {"my-subject"},
+ "aud": {"audience-1", "audience-2", "audience-3"},
+ "groups": {"group1", "group2"},
}
result := mapToStringClaims(claims)
diff --git a/central/auth/m2m/id_token.go b/central/auth/m2m/id_token.go
index b5851c50b8935..ecf420ec35f1c 100644
--- a/central/auth/m2m/id_token.go
+++ b/central/auth/m2m/id_token.go
@@ -1,16 +1,26 @@
package m2m
import (
+ "strings"
+
"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
"github.com/stackrox/rox/pkg/errox"
)
-// IssuerFromRawIDToken retrieves the issuer from a raw ID token.
+const KubernetesTokenIssuer = "https://kubernetes"
+
+// IssuerFromRawIDToken retrieves the issuer from a raw ID token. If the token
+// is not a JWT, assume Kubernetes opaque token.
// In case the token is malformed (i.e. jwt.ErrTokenMalformed is met), it will return an error.
// Other errors such as an expired token will be ignored.
// Note: This does **not** verify the token's signature or any other claim value.
func IssuerFromRawIDToken(rawIDToken string) (string, error) {
+ if strings.HasPrefix(rawIDToken, "sha256~") {
+ // Not a JWT. Assume Kubernetes opaque token.
+ return KubernetesTokenIssuer, nil
+ }
+
standardClaims := &jwt.RegisteredClaims{}
// Explicitly ignore the signature of the ID token for now.
// This will be handled in a latter part, when the metadata from the provider will be used to verify the signature.
diff --git a/central/auth/m2m/kube_token_review.go b/central/auth/m2m/kube_token_review.go
new file mode 100644
index 0000000000000..88b58511914d9
--- /dev/null
+++ b/central/auth/m2m/kube_token_review.go
@@ -0,0 +1,48 @@
+package m2m
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/pkg/errors"
+ v1 "k8s.io/api/authentication/v1"
+ metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+)
+
+// kubeTokenReviewVerifier verifies tokens using the Kubernetes TokenReview API.
+type kubeTokenReviewVerifier struct {
+ clientset kubernetes.Interface
+}
+
+func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
+ tr := &v1.TokenReview{
+ Spec: v1.TokenReviewSpec{
+ Token: rawIDToken,
+ },
+ }
+ trResp, err := k.clientset.AuthenticationV1().TokenReviews().
+ Create(ctx, tr, metaV1.CreateOptions{})
+ if err != nil {
+ return nil, errors.Wrap(err, "performing TokenReview request")
+ }
+ if !trResp.Status.Authenticated {
+ return nil, errors.Errorf("token not authenticated: %s", trResp.Status.Error)
+ }
+
+ claims := map[string]any{
+ "sub": trResp.Status.User.UID,
+ "name": trResp.Status.User.Username,
+ "groups": trResp.Status.User.Groups,
+ }
+ rawClaims, _ := json.Marshal(claims)
+
+ token := &IDToken{
+ Subject: trResp.Status.User.UID,
+ Claims: func(v any) error {
+ return json.Unmarshal(rawClaims, v)
+ },
+ Audience: trResp.Status.Audiences,
+ }
+ return token, nil
+}
diff --git a/central/auth/m2m/role_mapper_test.go b/central/auth/m2m/role_mapper_test.go
index eb6a8e5ede8b1..eece55903c4b3 100644
--- a/central/auth/m2m/role_mapper_test.go
+++ b/central/auth/m2m/role_mapper_test.go
@@ -27,6 +27,7 @@ func TestResolveRolesForClaims(t *testing.T) {
"aud": {"something", "somewhere"},
"repository": {"github.com/sample-org/sample-repo:main:062348SHA"},
"iss": {"https://stackrox.io"},
+ "groups": {"system:admins"},
}
config := &storage.AuthMachineToMachineConfig{
Mappings: []*storage.AuthMachineToMachineConfig_Mapping{
@@ -59,6 +60,10 @@ func TestResolveRolesForClaims(t *testing.T) {
Key: "iss",
ValueExpression: ".*",
Role: authn.NoneRole,
+ }, {
+ Key: "groups",
+ ValueExpression: "system:admins",
+ Role: "Analyst",
},
},
}
@@ -79,3 +84,31 @@ func TestResolveRolesForClaims(t *testing.T) {
assert.NoError(t, err)
assert.ElementsMatch(t, resolvedRoles, []permissions.ResolvedRole{roles["Admin"], roles["Analyst"], roles["roxctl"]})
}
+
+func TestResolveRolesForClaimsList(t *testing.T) {
+ claims := map[string][]string{
+ "groups": {"system:admins"},
+ }
+ config := &storage.AuthMachineToMachineConfig{
+ Mappings: []*storage.AuthMachineToMachineConfig_Mapping{
+ {
+ Key: "groups",
+ ValueExpression: "system:admins",
+ Role: "Analyst",
+ },
+ },
+ }
+ roles := map[string]permissions.ResolvedRole{
+ "Analyst": &testResolvedRole{name: "Analyst"},
+ }
+
+ roleDSMock := mocks.NewMockDataStore(gomock.NewController(t))
+
+ for roleName, resolvedRole := range roles {
+ roleDSMock.EXPECT().GetAndResolveRole(gomock.Any(), roleName).Return(resolvedRole, nil)
+ }
+
+ resolvedRoles, err := resolveRolesForClaims(context.Background(), claims, roleDSMock, config.GetMappings(), createRegexp(config))
+ assert.NoError(t, err)
+ assert.ElementsMatch(t, resolvedRoles, []permissions.ResolvedRole{roles["Analyst"]})
+}
diff --git a/central/auth/m2m/verifier.go b/central/auth/m2m/verifier.go
index fb89f3b873c71..10a0acaf69cdb 100644
--- a/central/auth/m2m/verifier.go
+++ b/central/auth/m2m/verifier.go
@@ -13,7 +13,9 @@ import (
"github.com/pkg/errors"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/httputil/proxy"
+ "github.com/stackrox/rox/pkg/k8sutil"
"github.com/stackrox/rox/pkg/logging"
+ "k8s.io/client-go/kubernetes"
)
const (
@@ -30,8 +32,14 @@ var (
_ tokenVerifier = (*genericTokenVerifier)(nil)
)
+type IDToken struct {
+ Claims func(any) error
+ Subject string
+ Audience []string
+}
+
type tokenVerifier interface {
- VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error)
+ VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error)
}
type authenticatedRoundTripper struct {
@@ -68,7 +76,8 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
}
roundTripper := proxy.RoundTripper(proxy.WithTLSConfig(tlsConfig))
- if config.Type == storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT {
+ switch config.Type {
+ case storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT:
token, err := kubeServiceAccountTokenReader{}.readToken()
if err != nil {
return nil, errors.Wrap(err, "Failed to read kube service account token")
@@ -77,6 +86,17 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
// By default k8s requires authentication to fetch the OIDC resources for service account tokens
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-issuer-discovery
roundTripper = authenticatedRoundTripper{roundTripper: roundTripper, token: token}
+
+ case storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW:
+ cfg, err := k8sutil.GetK8sInClusterConfig()
+ if err != nil {
+ return nil, err
+ }
+ c, err := kubernetes.NewForConfig(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &kubeTokenReviewVerifier{c}, nil
}
provider, err := oidc.NewProvider(
@@ -94,7 +114,7 @@ type genericTokenVerifier struct {
provider *oidc.Provider
}
-func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
+func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
verifier := g.provider.Verifier(&oidc.Config{
// We currently provide no config to expose the client ID that's associated with the ID token.
// The reason for this is the following:
@@ -106,7 +126,15 @@ func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken str
SkipClientIDCheck: true,
})
- return verifier.Verify(ctx, rawIDToken)
+ token, err := verifier.Verify(ctx, rawIDToken)
+ if err != nil {
+ return nil, err
+ }
+ return &IDToken{
+ Subject: token.Subject,
+ Audience: token.Audience,
+ Claims: token.Claims,
+ }, err
}
func tlsConfigWithCustomCertPool() (*tls.Config, error) {
diff --git a/generated/api/v1/auth_service.pb.go b/generated/api/v1/auth_service.pb.go
index 2a2d7e3ed081a..ddd122a01e0c9 100644
--- a/generated/api/v1/auth_service.pb.go
+++ b/generated/api/v1/auth_service.pb.go
@@ -33,6 +33,7 @@ const (
AuthMachineToMachineConfig_GENERIC AuthMachineToMachineConfig_Type = 0
AuthMachineToMachineConfig_GITHUB_ACTIONS AuthMachineToMachineConfig_Type = 1
AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT AuthMachineToMachineConfig_Type = 2
+ AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW AuthMachineToMachineConfig_Type = 3
)
// Enum value maps for AuthMachineToMachineConfig_Type.
@@ -41,11 +42,13 @@ var (
0: "GENERIC",
1: "GITHUB_ACTIONS",
2: "KUBE_SERVICE_ACCOUNT",
+ 3: "KUBE_TOKEN_REVIEW",
}
AuthMachineToMachineConfig_Type_value = map[string]int32{
"GENERIC": 0,
"GITHUB_ACTIONS": 1,
"KUBE_SERVICE_ACCOUNT": 2,
+ "KUBE_TOKEN_REVIEW": 3,
}
)
@@ -752,7 +755,7 @@ const file_api_v1_auth_service_proto_rawDesc = "" +
"\tuser_info\x18\x06 \x01(\v2\x11.storage.UserInfoR\buserInfo\x12:\n" +
"\x0fuser_attributes\x18\a \x03(\v2\x11.v1.UserAttributeR\x0euserAttributes\x12\x1b\n" +
"\tidp_token\x18\b \x01(\tR\bidpTokenB\x04\n" +
- "\x02id\"\x9c\x03\n" +
+ "\x02id\"\xb3\x03\n" +
"\x1aAuthMachineToMachineConfig\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x127\n" +
"\x04type\x18\x02 \x01(\x0e2#.v1.AuthMachineToMachineConfig.TypeR\x04type\x12:\n" +
@@ -762,11 +765,12 @@ const file_api_v1_auth_service_proto_rawDesc = "" +
"\aMapping\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12)\n" +
"\x10value_expression\x18\x02 \x01(\tR\x0fvalueExpression\x12\x12\n" +
- "\x04role\x18\x03 \x01(\tR\x04role\"A\n" +
+ "\x04role\x18\x03 \x01(\tR\x04role\"X\n" +
"\x04Type\x12\v\n" +
"\aGENERIC\x10\x00\x12\x12\n" +
"\x0eGITHUB_ACTIONS\x10\x01\x12\x18\n" +
- "\x14KUBE_SERVICE_ACCOUNT\x10\x02\"b\n" +
+ "\x14KUBE_SERVICE_ACCOUNT\x10\x02\x12\x15\n" +
+ "\x11KUBE_TOKEN_REVIEW\x10\x03\"b\n" +
"&ListAuthMachineToMachineConfigResponse\x128\n" +
"\aconfigs\x18\x01 \x03(\v2\x1e.v1.AuthMachineToMachineConfigR\aconfigs\"_\n" +
"%GetAuthMachineToMachineConfigResponse\x126\n" +
diff --git a/generated/api/v1/auth_service.swagger.json b/generated/api/v1/auth_service.swagger.json
index 005774e34fa54..f555c5fe2fa43 100644
--- a/generated/api/v1/auth_service.swagger.json
+++ b/generated/api/v1/auth_service.swagger.json
@@ -583,7 +583,8 @@
"enum": [
"GENERIC",
"GITHUB_ACTIONS",
- "KUBE_SERVICE_ACCOUNT"
+ "KUBE_SERVICE_ACCOUNT",
+ "KUBE_TOKEN_REVIEW"
],
"default": "GENERIC",
"description": "The type of the auth machine to machine config.\nCurrently supports GitHub actions or any other generic OIDC provider to use for verifying and\nexchanging the token."
diff --git a/generated/storage/auth_machine_to_machine.pb.go b/generated/storage/auth_machine_to_machine.pb.go
index bcaf0227b6edb..cd6f5ae7a44d1 100644
--- a/generated/storage/auth_machine_to_machine.pb.go
+++ b/generated/storage/auth_machine_to_machine.pb.go
@@ -27,6 +27,7 @@ const (
AuthMachineToMachineConfig_GENERIC AuthMachineToMachineConfig_Type = 0
AuthMachineToMachineConfig_GITHUB_ACTIONS AuthMachineToMachineConfig_Type = 1
AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT AuthMachineToMachineConfig_Type = 2
+ AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW AuthMachineToMachineConfig_Type = 3
)
// Enum value maps for AuthMachineToMachineConfig_Type.
@@ -35,11 +36,13 @@ var (
0: "GENERIC",
1: "GITHUB_ACTIONS",
2: "KUBE_SERVICE_ACCOUNT",
+ 3: "KUBE_TOKEN_REVIEW",
}
AuthMachineToMachineConfig_Type_value = map[string]int32{
"GENERIC": 0,
"GITHUB_ACTIONS": 1,
"KUBE_SERVICE_ACCOUNT": 2,
+ "KUBE_TOKEN_REVIEW": 3,
}
)
@@ -216,7 +219,7 @@ var File_storage_auth_machine_to_machine_proto protoreflect.FileDescriptor
const file_storage_auth_machine_to_machine_proto_rawDesc = "" +
"\n" +
- "%storage/auth_machine_to_machine.proto\x12\astorage\"\xa6\x03\n" +
+ "%storage/auth_machine_to_machine.proto\x12\astorage\"\xbd\x03\n" +
"\x1aAuthMachineToMachineConfig\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12<\n" +
"\x04type\x18\x02 \x01(\x0e2(.storage.AuthMachineToMachineConfig.TypeR\x04type\x12:\n" +
@@ -226,11 +229,12 @@ const file_storage_auth_machine_to_machine_proto_rawDesc = "" +
"\aMapping\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12)\n" +
"\x10value_expression\x18\x02 \x01(\tR\x0fvalueExpression\x12\x12\n" +
- "\x04role\x18\x03 \x01(\tR\x04role\"A\n" +
+ "\x04role\x18\x03 \x01(\tR\x04role\"X\n" +
"\x04Type\x12\v\n" +
"\aGENERIC\x10\x00\x12\x12\n" +
"\x0eGITHUB_ACTIONS\x10\x01\x12\x18\n" +
- "\x14KUBE_SERVICE_ACCOUNT\x10\x02B.\n" +
+ "\x14KUBE_SERVICE_ACCOUNT\x10\x02\x12\x15\n" +
+ "\x11KUBE_TOKEN_REVIEW\x10\x03B.\n" +
"\x19io.stackrox.proto.storageZ\x11./storage;storageb\x06proto3"
var (
diff --git a/proto/api/v1/auth_service.proto b/proto/api/v1/auth_service.proto
index 7faf9ba6b8eea..1655682ddc305 100644
--- a/proto/api/v1/auth_service.proto
+++ b/proto/api/v1/auth_service.proto
@@ -48,6 +48,7 @@ message AuthMachineToMachineConfig {
GENERIC = 0;
GITHUB_ACTIONS = 1;
KUBE_SERVICE_ACCOUNT = 2;
+ KUBE_TOKEN_REVIEW = 3;
}
Type type = 2;
diff --git a/proto/storage/auth_machine_to_machine.proto b/proto/storage/auth_machine_to_machine.proto
index d4074efc06810..35106a9cf6209 100644
--- a/proto/storage/auth_machine_to_machine.proto
+++ b/proto/storage/auth_machine_to_machine.proto
@@ -16,6 +16,7 @@ message AuthMachineToMachineConfig {
GENERIC = 0;
GITHUB_ACTIONS = 1;
KUBE_SERVICE_ACCOUNT = 2;
+ KUBE_TOKEN_REVIEW = 3;
}
Type type = 2;
diff --git a/ui/apps/platform/plugin-cr.yaml b/ui/apps/platform/plugin-cr.yaml
new file mode 100644
index 0000000000000..afdd1b79a0b8c
--- /dev/null
+++ b/ui/apps/platform/plugin-cr.yaml
@@ -0,0 +1,21 @@
+apiVersion: console.openshift.io/v1
+kind: ConsolePlugin
+metadata:
+ name: 'advanced-cluster-security-plugin'
+spec:
+ displayName: 'Advanced Cluster Security plugin'
+ backend:
+ service:
+ name: central
+ namespace: stackrox
+ port: 443
+ basePath: '/'
+ proxy:
+ - type: Service
+ alias: acs-api-service
+ endpoint:
+ type: Service
+ service:
+ name: central
+ namespace: stackrox
+ port: 443
diff --git a/ui/apps/platform/src/ConsolePlugin/PluginContent.tsx b/ui/apps/platform/src/ConsolePlugin/PluginContent.tsx
new file mode 100644
index 0000000000000..2b7a5a3f4f344
--- /dev/null
+++ b/ui/apps/platform/src/ConsolePlugin/PluginContent.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import usePermissions from 'hooks/usePermissions';
+import LoadingSection from 'Components/PatternFly/LoadingSection';
+
+function PluginContent({ children }: { children: React.ReactNode }) {
+ const { isLoadingPermissions } = usePermissions();
+
+ if (isLoadingPermissions) {
+ return