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