From cd152c966c08eb42bc63420e6575c625c5b42f70 Mon Sep 17 00:00:00 2001
From: David Vail
Date: Mon, 28 Jul 2025 13:57:26 -0400
Subject: [PATCH 01/10] Adds context for OCP plugin user permissions
---
.../src/ConsolePlugin/PluginContent.tsx | 15 +++++++++++
.../src/ConsolePlugin/PluginProvider.tsx | 20 +++++++++++---
.../SecurityVulnerabilitiesPage/Index.tsx | 26 ++++++++++++-------
.../ConsolePlugin/UserPermissionProvider.tsx | 17 ++++++++++++
ui/apps/platform/webpack.ocp-plugin.config.js | 8 ++++++
5 files changed, 74 insertions(+), 12 deletions(-)
create mode 100644 ui/apps/platform/src/ConsolePlugin/PluginContent.tsx
create mode 100644 ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
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 ;
+ }
+
+ return <>{children}>;
+}
+
+export default PluginContent;
diff --git a/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx b/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
index 14a1cc381a2d2..e45ef93dbd6e3 100644
--- a/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
+++ b/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { ApolloProvider } from '@apollo/client';
import axios from 'services/instance';
import configureApolloClient from '../init/configureApolloClient';
import consoleFetchAxiosAdapter from './consoleFetchAxiosAdapter';
+import UserPermissionProvider from './UserPermissionProvider';
+import PluginContent from './PluginContent';
// The console requires a custom fetch implementation via `consoleFetch` to correctly pass headers such
// as X-CSRFToken to API requests. All of our current code uses `axios` to make API requests, so we need
@@ -13,6 +15,18 @@ axios.defaults.adapter = (config) => consoleFetchAxiosAdapter(proxyBaseURL, conf
const apolloClient = configureApolloClient();
-export default function PluginProvider({ children }) {
- return {children};
+export function PluginProvider({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// If there is any data that needs to be shared across plugin entry points that isn't covered by
+// a general purpose hook, we can add it here.
+export function usePluginContext() {
+ return useMemo(() => ({}), []);
}
diff --git a/ui/apps/platform/src/ConsolePlugin/SecurityVulnerabilitiesPage/Index.tsx b/ui/apps/platform/src/ConsolePlugin/SecurityVulnerabilitiesPage/Index.tsx
index 799b0c3e9e544..c24aa44427c24 100644
--- a/ui/apps/platform/src/ConsolePlugin/SecurityVulnerabilitiesPage/Index.tsx
+++ b/ui/apps/platform/src/ConsolePlugin/SecurityVulnerabilitiesPage/Index.tsx
@@ -1,24 +1,32 @@
import React from 'react';
import { PageSection, Title } from '@patternfly/react-core';
import { CheckCircleIcon } from '@patternfly/react-icons';
+import usePermissions from 'hooks/usePermissions';
import SummaryCounts from 'Containers/Dashboard/SummaryCounts';
import ViolationsByPolicyCategory from 'Containers/Dashboard/Widgets/ViolationsByPolicyCategory';
-import PluginProvider from '../PluginProvider';
export function Index() {
+ const { hasReadAccess } = usePermissions();
+ const hasReadAccessForAlert = hasReadAccess('Alert');
+ const hasReadAccessForCluster = hasReadAccess('Cluster');
+ const hasReadAccessForDeployment = hasReadAccess('Deployment');
+ const hasReadAccessForImage = hasReadAccess('Image');
+ const hasReadAccessForNode = hasReadAccess('Node');
+ const hasReadAccessForSecret = hasReadAccess('Secret');
+
return (
-
+ <>
{'Hello, Plugin!'}
@@ -31,6 +39,6 @@ export function Index() {
{'Your plugin is working.'}
-
+ >
);
}
diff --git a/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx b/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
new file mode 100644
index 0000000000000..d9e9a14907c5a
--- /dev/null
+++ b/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { UserPermissionContext } from 'hooks/usePermissions';
+
+import { fetchUserRolePermissions } from 'services/RolesService';
+import useRestQuery from 'hooks/useRestQuery';
+
+export default function UserPermissionProvider({ children }: { children: React.ReactNode }) {
+ const { data, isLoading } = useRestQuery(fetchUserRolePermissions);
+ const userRolePermissions = data?.response || { resourceToAccess: {} };
+ const isLoadingPermissions = isLoading;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/ui/apps/platform/webpack.ocp-plugin.config.js b/ui/apps/platform/webpack.ocp-plugin.config.js
index 34d1b9e5f90f3..81f2be27ffce2 100644
--- a/ui/apps/platform/webpack.ocp-plugin.config.js
+++ b/ui/apps/platform/webpack.ocp-plugin.config.js
@@ -98,6 +98,7 @@ const config = {
displayName: 'Red Hat Advanced Cluster Security for OpenShift',
description: 'OCP Console Plugin for Advanced Cluster Security',
exposedModules: {
+ context: './ConsolePlugin/PluginProvider',
SecurityVulnerabilitiesPage:
'./ConsolePlugin/SecurityVulnerabilitiesPage/Index',
WorkloadSecurityTab: './ConsolePlugin/WorkloadSecurityTab/Index',
@@ -111,6 +112,13 @@ const config = {
},
extensions: [
// Security Vulnerabilities Page
+ {
+ type: 'console.context-provider',
+ properties: {
+ provider: { $codeRef: 'context.PluginProvider' },
+ useValueHook: { $codeRef: 'context.usePluginContext' },
+ },
+ },
{
type: 'console.page/route',
properties: {
From 9dc7f2127a6cf8b2096a63a48924f5f8bf20b53f Mon Sep 17 00:00:00 2001
From: David Vail
Date: Mon, 4 Aug 2025 07:18:32 -0400
Subject: [PATCH 02/10] .
---
ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx | 4 ++--
ui/apps/platform/webpack.ocp-plugin.config.js | 3 ++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx b/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
index d9e9a14907c5a..f4a711c144069 100644
--- a/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
+++ b/ui/apps/platform/src/ConsolePlugin/UserPermissionProvider.tsx
@@ -1,10 +1,10 @@
-import React from 'react';
+import React, { type ReactNode } from 'react';
import { UserPermissionContext } from 'hooks/usePermissions';
import { fetchUserRolePermissions } from 'services/RolesService';
import useRestQuery from 'hooks/useRestQuery';
-export default function UserPermissionProvider({ children }: { children: React.ReactNode }) {
+export function UserPermissionProvider({ children }: { children: ReactNode }) {
const { data, isLoading } = useRestQuery(fetchUserRolePermissions);
const userRolePermissions = data?.response || { resourceToAccess: {} };
const isLoadingPermissions = isLoading;
diff --git a/ui/apps/platform/webpack.ocp-plugin.config.js b/ui/apps/platform/webpack.ocp-plugin.config.js
index 81f2be27ffce2..3b184eb64ec4a 100644
--- a/ui/apps/platform/webpack.ocp-plugin.config.js
+++ b/ui/apps/platform/webpack.ocp-plugin.config.js
@@ -111,7 +111,7 @@ const config = {
},
},
extensions: [
- // Security Vulnerabilities Page
+ // General Context Provider to be shared across all extensions
{
type: 'console.context-provider',
properties: {
@@ -119,6 +119,7 @@ const config = {
useValueHook: { $codeRef: 'context.usePluginContext' },
},
},
+ // Security Vulnerabilities Page
{
type: 'console.page/route',
properties: {
From 6e37741d005bf550b8afa670385f1c58b616325f Mon Sep 17 00:00:00 2001
From: David Vail
Date: Mon, 4 Aug 2025 10:58:10 -0400
Subject: [PATCH 03/10] fix
---
ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx b/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
index e45ef93dbd6e3..ad559aa69ef1d 100644
--- a/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
+++ b/ui/apps/platform/src/ConsolePlugin/PluginProvider.tsx
@@ -1,10 +1,10 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, type ReactNode } from 'react';
import { ApolloProvider } from '@apollo/client';
import axios from 'services/instance';
import configureApolloClient from '../init/configureApolloClient';
import consoleFetchAxiosAdapter from './consoleFetchAxiosAdapter';
-import UserPermissionProvider from './UserPermissionProvider';
+import { UserPermissionProvider } from './UserPermissionProvider';
import PluginContent from './PluginContent';
// The console requires a custom fetch implementation via `consoleFetch` to correctly pass headers such
@@ -15,7 +15,7 @@ axios.defaults.adapter = (config) => consoleFetchAxiosAdapter(proxyBaseURL, conf
const apolloClient = configureApolloClient();
-export function PluginProvider({ children }: { children: React.ReactNode }) {
+export function PluginProvider({ children }: { children: ReactNode }) {
return (
From aa7a226bb1783c40d9cf8250b37280c23ab53864 Mon Sep 17 00:00:00 2001
From: David Vail
Date: Mon, 4 Aug 2025 10:45:03 -0400
Subject: [PATCH 04/10] Test branch for deploying plugin via CR
From e8abe736d9f6065ff0c2563bd4a2120f1edf391a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Petrov?=
Date: Mon, 21 Jul 2025 10:15:00 +0200
Subject: [PATCH 05/10] feat(m2m): exchange opaque k8s tokens
---
central/auth/m2m/claims.go | 7 +-
central/auth/m2m/id_token.go | 12 ++-
central/auth/m2m/kube_token_review.go | 97 +++++++++++++++++++
central/auth/m2m/verifier.go | 30 +++++-
generated/api/v1/auth_service.pb.go | 10 +-
generated/api/v1/auth_service.swagger.json | 3 +-
.../storage/auth_machine_to_machine.pb.go | 10 +-
proto/api/v1/auth_service.proto | 1 +
proto/storage/auth_machine_to_machine.proto | 1 +
9 files changed, 155 insertions(+), 16 deletions(-)
create mode 100644 central/auth/m2m/kube_token_review.go
diff --git a/central/auth/m2m/claims.go b/central/auth/m2m/claims.go
index 93fd3c8ccad26..cc4d3a19bd8fd 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,7 +16,7 @@ var (
)
type claimExtractor interface {
- ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error)
+ ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error)
}
func newClaimExtractorFromConfig(config *storage.AuthMachineToMachineConfig) claimExtractor {
@@ -32,7 +31,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 +108,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/id_token.go b/central/auth/m2m/id_token.go
index b5851c50b8935..f134f05dcbc4e 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 = "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..149430a2f47bf
--- /dev/null
+++ b/central/auth/m2m/kube_token_review.go
@@ -0,0 +1,97 @@
+package m2m
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/pkg/errors"
+ "github.com/stackrox/rox/pkg/utils"
+)
+
+// kubeTokenReviewVerifier verifies tokens using the Kubernetes TokenReview API.
+type kubeTokenReviewVerifier struct {
+ apiServer string
+ client *http.Client
+}
+
+// tokenReviewRequest represents the payload for the Kubernetes TokenReview API.
+type tokenReviewRequest struct {
+ APIVersion string `json:"apiVersion"`
+ Kind string `json:"kind"`
+ Spec struct {
+ Token string `json:"token"`
+ } `json:"spec"`
+}
+
+func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
+ // Prepare TokenReview request
+ tr := tokenReviewRequest{
+ "authentication.k8s.io/v1",
+ "TokenReview",
+ struct {
+ Token string `json:"token"`
+ }{rawIDToken},
+ }
+ reqBody, _ := json.Marshal(tr)
+ req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/apis/authentication.k8s.io/v1/tokenreviews",
+ k.apiServer),
+ bytes.NewReader(reqBody))
+ if err != nil {
+ return nil, errors.Wrap(err, "creating TokenReview request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := k.client.Do(req)
+ if err != nil {
+ return nil, errors.Wrap(err, "performing TokenReview request")
+ }
+ defer utils.IgnoreError(resp.Body.Close)
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.Errorf("TokenReview API returned status %d", resp.StatusCode)
+ }
+
+ var trResp struct {
+ Status struct {
+ Authenticated bool `json:"authenticated"`
+ User struct {
+ Username string `json:"username"`
+ UID string `json:"uid"`
+ Groups []string `json:"groups"`
+ } `json:"user"`
+ Audiences []string `json:"audiences"`
+ Error string `json:"error"`
+ } `json:"status"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&trResp); err != nil {
+ return nil, errors.Wrap(err, "decoding TokenReview response")
+ }
+ if !trResp.Status.Authenticated {
+ return nil, errors.Errorf("token not authenticated: %s", trResp.Status.Error)
+ }
+
+ // Construct a minimal oidc.IDToken with user info in claims
+ claims := map[string]interface{}{
+ "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,
+ /*
+ Issuer: k.apiServer,
+ Expiry: time.Now().Add(5 * time.Minute), // TokenReview doesn't provide expiry, so set a short one.
+ IssuedAt: time.Now(),
+ },*/
+ Claims: func(v interface{}) error {
+ return json.Unmarshal(rawClaims, v)
+ },
+ Audience: trResp.Status.Audiences,
+ }
+ return token, nil
+}
diff --git a/central/auth/m2m/verifier.go b/central/auth/m2m/verifier.go
index fb89f3b873c71..a0ac159974ccb 100644
--- a/central/auth/m2m/verifier.go
+++ b/central/auth/m2m/verifier.go
@@ -30,8 +30,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 +74,7 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
}
roundTripper := proxy.RoundTripper(proxy.WithTLSConfig(tlsConfig))
- if config.Type == storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT {
+ if config.Type == storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT || config.Type == storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW {
token, err := kubeServiceAccountTokenReader{}.readToken()
if err != nil {
return nil, errors.Wrap(err, "Failed to read kube service account token")
@@ -77,6 +83,14 @@ 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}
+
+ if config.Type == storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW {
+ // Use the TokenReview API to verify the token
+ return &kubeTokenReviewVerifier{
+ apiServer: config.GetIssuer(),
+ client: &http.Client{Timeout: time.Minute, Transport: roundTripper},
+ }, nil
+ }
}
provider, err := oidc.NewProvider(
@@ -94,7 +108,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 +120,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;
From 54b5f10c67390f8d55a8a91e76a759a21d3e272f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl?=
Date: Mon, 21 Jul 2025 13:48:03 +0200
Subject: [PATCH 06/10] Apply suggestion from @parametalol
---
central/auth/m2m/id_token.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/central/auth/m2m/id_token.go b/central/auth/m2m/id_token.go
index f134f05dcbc4e..ecf420ec35f1c 100644
--- a/central/auth/m2m/id_token.go
+++ b/central/auth/m2m/id_token.go
@@ -8,7 +8,7 @@ import (
"github.com/stackrox/rox/pkg/errox"
)
-const KubernetesTokenIssuer = "kubernetes"
+const KubernetesTokenIssuer = "https://kubernetes"
// IssuerFromRawIDToken retrieves the issuer from a raw ID token. If the token
// is not a JWT, assume Kubernetes opaque token.
From 09f4eed6995d67951981d2befae39ffd9972b3b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Petrov?=
Date: Mon, 21 Jul 2025 15:03:43 +0200
Subject: [PATCH 07/10] fix(m2m): use in-cluster k8s config
---
central/auth/m2m/kube_token_review.go | 66 +++++++++++++--------------
central/auth/m2m/verifier.go | 20 +++++---
2 files changed, 46 insertions(+), 40 deletions(-)
diff --git a/central/auth/m2m/kube_token_review.go b/central/auth/m2m/kube_token_review.go
index 149430a2f47bf..053f2b9a8d888 100644
--- a/central/auth/m2m/kube_token_review.go
+++ b/central/auth/m2m/kube_token_review.go
@@ -1,49 +1,53 @@
package m2m
import (
- "bytes"
"context"
"encoding/json"
- "fmt"
- "net/http"
"github.com/pkg/errors"
- "github.com/stackrox/rox/pkg/utils"
+ 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 {
- apiServer string
- client *http.Client
-}
-
-// tokenReviewRequest represents the payload for the Kubernetes TokenReview API.
-type tokenReviewRequest struct {
- APIVersion string `json:"apiVersion"`
- Kind string `json:"kind"`
- Spec struct {
- Token string `json:"token"`
- } `json:"spec"`
+ clientset kubernetes.Interface
}
func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
- // Prepare TokenReview request
- tr := tokenReviewRequest{
- "authentication.k8s.io/v1",
- "TokenReview",
- struct {
- Token string `json:"token"`
- }{rawIDToken},
+ tr := &v1.TokenReview{
+ Spec: v1.TokenReviewSpec{
+ Token: rawIDToken,
+ },
}
- reqBody, _ := json.Marshal(tr)
- req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/apis/authentication.k8s.io/v1/tokenreviews",
- k.apiServer),
- bytes.NewReader(reqBody))
+ trResp, err := k.clientset.AuthenticationV1().TokenReviews().
+ Create(ctx, tr, metaV1.CreateOptions{})
if err != nil {
- return nil, errors.Wrap(err, "creating TokenReview request")
+ 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,
}
- req.Header.Set("Content-Type", "application/json")
+ 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
+}
+/*
resp, err := k.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "performing TokenReview request")
@@ -83,11 +87,6 @@ func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken
token := &IDToken{
Subject: trResp.Status.User.UID,
- /*
- Issuer: k.apiServer,
- Expiry: time.Now().Add(5 * time.Minute), // TokenReview doesn't provide expiry, so set a short one.
- IssuedAt: time.Now(),
- },*/
Claims: func(v interface{}) error {
return json.Unmarshal(rawClaims, v)
},
@@ -95,3 +94,4 @@ func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken
}
return token, nil
}
+*/
diff --git a/central/auth/m2m/verifier.go b/central/auth/m2m/verifier.go
index a0ac159974ccb..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 (
@@ -74,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 || config.Type == storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW {
+ 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")
@@ -84,13 +87,16 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-issuer-discovery
roundTripper = authenticatedRoundTripper{roundTripper: roundTripper, token: token}
- if config.Type == storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW {
- // Use the TokenReview API to verify the token
- return &kubeTokenReviewVerifier{
- apiServer: config.GetIssuer(),
- client: &http.Client{Timeout: time.Minute, Transport: roundTripper},
- }, nil
+ 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(
From ce2e2d2b3c60e1d466d110674ac1a0f994f85f4e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Petrov?=
Date: Mon, 21 Jul 2025 16:24:13 +0200
Subject: [PATCH 08/10] fix(m2m): allow []any claim value if any is string
---
central/auth/m2m/exchanger.go | 6 ++++
central/auth/m2m/exchanger_test.go | 6 ++--
central/auth/m2m/kube_token_review.go | 49 ---------------------------
3 files changed, 10 insertions(+), 51 deletions(-)
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/kube_token_review.go b/central/auth/m2m/kube_token_review.go
index 053f2b9a8d888..88b58511914d9 100644
--- a/central/auth/m2m/kube_token_review.go
+++ b/central/auth/m2m/kube_token_review.go
@@ -46,52 +46,3 @@ func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken
}
return token, nil
}
-
-/*
- resp, err := k.client.Do(req)
- if err != nil {
- return nil, errors.Wrap(err, "performing TokenReview request")
- }
- defer utils.IgnoreError(resp.Body.Close)
-
- if resp.StatusCode != http.StatusOK {
- return nil, errors.Errorf("TokenReview API returned status %d", resp.StatusCode)
- }
-
- var trResp struct {
- Status struct {
- Authenticated bool `json:"authenticated"`
- User struct {
- Username string `json:"username"`
- UID string `json:"uid"`
- Groups []string `json:"groups"`
- } `json:"user"`
- Audiences []string `json:"audiences"`
- Error string `json:"error"`
- } `json:"status"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&trResp); err != nil {
- return nil, errors.Wrap(err, "decoding TokenReview response")
- }
- if !trResp.Status.Authenticated {
- return nil, errors.Errorf("token not authenticated: %s", trResp.Status.Error)
- }
-
- // Construct a minimal oidc.IDToken with user info in claims
- claims := map[string]interface{}{
- "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 interface{}) error {
- return json.Unmarshal(rawClaims, v)
- },
- Audience: trResp.Status.Audiences,
- }
- return token, nil
-}
-*/
From 1f9061bb2fa66693e5a9336daf1505a8ee749569 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Petrov?=
Date: Mon, 21 Jul 2025 17:34:48 +0200
Subject: [PATCH 09/10] fix(m2m): try to extract generic claims
---
central/auth/m2m/claims.go | 5 ++++-
central/auth/m2m/role_mapper_test.go | 33 ++++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/central/auth/m2m/claims.go b/central/auth/m2m/claims.go
index cc4d3a19bd8fd..899dbeb1b9c72 100644
--- a/central/auth/m2m/claims.go
+++ b/central/auth/m2m/claims.go
@@ -20,7 +20,10 @@ type claimExtractor interface {
}
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()}
}
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"]})
+}
From 102fa69159956dad9955482fc9cb643e126a64b2 Mon Sep 17 00:00:00 2001
From: David Vail
Date: Mon, 4 Aug 2025 11:49:17 -0400
Subject: [PATCH 10/10] Add WIP cr
---
ui/apps/platform/plugin-cr.yaml | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 ui/apps/platform/plugin-cr.yaml
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