Skip to content

Commit cee6508

Browse files
committed
Adding access checks to MaaS package
1 parent b5cf206 commit cee6508

File tree

12 files changed

+234
-4
lines changed

12 files changed

+234
-4
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/julienschmidt/httprouter"
10+
k8s "github.com/opendatahub-io/maas-library/bff/internal/integrations/kubernetes"
11+
)
12+
13+
type AccessReviewRequest struct {
14+
Group string `json:"group"`
15+
Resource string `json:"resource"`
16+
Verb string `json:"verb"`
17+
Namespace string `json:"namespace,omitempty"`
18+
}
19+
20+
type AccessReviewResult struct {
21+
Allowed bool `json:"allowed"`
22+
}
23+
24+
// extractBearerToken returns the token from an Authorization: Bearer <token> header value,
25+
// or an empty string if the value is not a Bearer token.
26+
func extractBearerToken(authHeader string) string {
27+
if strings.HasPrefix(authHeader, "Bearer ") {
28+
return strings.TrimPrefix(authHeader, "Bearer ")
29+
}
30+
return ""
31+
}
32+
33+
// AccessReviewHandler handles POST /api/v1/access-review
34+
// It performs a SelfSubjectAccessReview via the BFF's Kubernetes client.
35+
//
36+
// Token selection (in priority order):
37+
// 1. Authorization: Bearer <token> — set by the ODH dashboard backend, and correctly
38+
// substituted with the impersonated user's token when using the ODH dev impersonation
39+
// feature (DEV_IMPERSONATE_USER). This ensures the SSAR evaluates the right identity.
40+
// 2. x-forwarded-access-token — fallback for standalone federated dev mode where the
41+
// webpack proxy injects the real user's token directly.
42+
func AccessReviewHandler(app *App, w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
43+
ctx := r.Context()
44+
45+
token := extractBearerToken(r.Header.Get("Authorization"))
46+
if token == "" {
47+
token = r.Header.Get("x-forwarded-access-token")
48+
}
49+
if token == "" {
50+
app.badRequestResponse(w, r, fmt.Errorf("no authentication token found in Authorization or x-forwarded-access-token headers"))
51+
return
52+
}
53+
54+
var request Envelope[AccessReviewRequest, None]
55+
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
56+
app.badRequestResponse(w, r, err)
57+
return
58+
}
59+
60+
client, err := k8s.NewTokenKubernetesClient(token, app.logger)
61+
if err != nil {
62+
app.serverErrorResponse(w, r, fmt.Errorf("failed to create Kubernetes client: %w", err))
63+
return
64+
}
65+
66+
allowed, err := client.CheckSelfAccess(ctx, request.Data.Group, request.Data.Resource, request.Data.Verb, request.Data.Namespace)
67+
if err != nil {
68+
app.serverErrorResponse(w, r, err)
69+
return
70+
}
71+
72+
responseEnvelope := Envelope[AccessReviewResult, None]{
73+
Data: AccessReviewResult{Allowed: allowed},
74+
}
75+
76+
if err := app.WriteJSON(w, http.StatusOK, responseEnvelope, nil); err != nil {
77+
app.serverErrorResponse(w, r, err)
78+
}
79+
}

packages/maas/bff/internal/api/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
HealthCheckPath = "/healthcheck"
3535
UserPath = constants.ApiPathPrefix + "/user"
3636
NamespacePath = constants.ApiPathPrefix + "/namespaces"
37+
IsMaasAdminPath = constants.ApiPathPrefix + "/is-maas-admin"
3738
)
3839

3940
type App struct {
@@ -182,6 +183,7 @@ func (app *App) Routes() http.Handler {
182183
attachSubscriptionHandlers(apiRouter, app)
183184
attachMaaSModelRefHandlers(apiRouter, app)
184185
apiRouter.GET(constants.ApiPathPrefix+"/models", handlerWithApp(app, ListModelsHandler))
186+
apiRouter.GET(IsMaasAdminPath, handlerWithApp(app, IsMaasAdminHandler))
185187

186188
// Minimal Kubernetes-backed starter endpoints TODO: Remove?
187189
apiRouter.GET(UserPath, app.UserHandler)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/julienschmidt/httprouter"
8+
k8s "github.com/opendatahub-io/maas-library/bff/internal/integrations/kubernetes"
9+
)
10+
11+
const (
12+
maasAdminGroup = "maas.opendatahub.io"
13+
maasAdminResource = "maasauthpolicies"
14+
maasAdminVerb = "create"
15+
maasAdminNamespace = "models-as-a-service"
16+
)
17+
18+
// IsMaasAdminHandler handles GET /api/v1/is-maas-admin
19+
// It checks whether the requesting user can create maasauthpolicies in the
20+
// models-as-a-service namespace, which is the signal for MaaS admin access.
21+
//
22+
// Token selection follows the same priority as AccessReviewHandler:
23+
// 1. Authorization: Bearer <token> — correctly substituted when using ODH impersonation
24+
// 2. x-forwarded-access-token — fallback for standalone federated dev mode
25+
func IsMaasAdminHandler(app *App, w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
26+
ctx := r.Context()
27+
28+
token := extractBearerToken(r.Header.Get("Authorization"))
29+
if token == "" {
30+
token = r.Header.Get("x-forwarded-access-token")
31+
}
32+
if token == "" {
33+
app.badRequestResponse(w, r, fmt.Errorf("no authentication token found in Authorization or x-forwarded-access-token headers"))
34+
return
35+
}
36+
37+
client, err := k8s.NewTokenKubernetesClient(token, app.logger)
38+
if err != nil {
39+
app.serverErrorResponse(w, r, fmt.Errorf("failed to create Kubernetes client: %w", err))
40+
return
41+
}
42+
43+
allowed, err := client.CheckSelfAccess(ctx, maasAdminGroup, maasAdminResource, maasAdminVerb, maasAdminNamespace)
44+
if err != nil {
45+
app.serverErrorResponse(w, r, err)
46+
return
47+
}
48+
49+
responseEnvelope := Envelope[AccessReviewResult, None]{
50+
Data: AccessReviewResult{Allowed: allowed},
51+
}
52+
53+
if err := app.WriteJSON(w, http.StatusOK, responseEnvelope, nil); err != nil {
54+
app.serverErrorResponse(w, r, err)
55+
}
56+
}

packages/maas/bff/internal/constants/api_routes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const (
1414
APIKeyBulkRevokePath = ApiPathPrefix + "/api-keys/bulk-revoke"
1515
APIKeyByIDPath = ApiPathPrefix + "/api-keys/:id"
1616

17+
// Access review
18+
IsMaasAdminPath = ApiPathPrefix + "/is-maas-admin"
19+
1720
// Subscription routes
1821
SubscriptionListPath = ApiPathPrefix + "/all-subscriptions"
1922
SubscriptionInfoPath = ApiPathPrefix + "/subscription-info/:name"

packages/maas/bff/internal/integrations/kubernetes/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ type KubernetesClientInterface interface {
1717
GetNamespaces(ctx context.Context, identity *RequestIdentity) ([]corev1.Namespace, error)
1818
IsClusterAdmin(identity *RequestIdentity) (bool, error)
1919
GetUser(identity *RequestIdentity) (string, error)
20+
CheckSelfAccess(ctx context.Context, group, resource, verb, namespace string) (bool, error)
2021
}

packages/maas/bff/internal/integrations/kubernetes/factory.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,5 +159,5 @@ func (f *TokenClientFactory) GetClient(ctx context.Context) (KubernetesClientInt
159159
return nil, fmt.Errorf("invalid or missing identity token")
160160
}
161161

162-
return newTokenKubernetesClient(identity.Token, f.Logger)
162+
return NewTokenKubernetesClient(identity.Token, f.Logger)
163163
}

packages/maas/bff/internal/integrations/kubernetes/internal_k8s_client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,30 @@ func (kc *InternalKubernetesClient) IsClusterAdmin(identity *RequestIdentity) (b
218218
return false, nil
219219
}
220220

221+
func (kc *InternalKubernetesClient) CheckSelfAccess(ctx context.Context, group, resource, verb, namespace string) (bool, error) {
222+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
223+
defer cancel()
224+
225+
sar := &authv1.SelfSubjectAccessReview{
226+
Spec: authv1.SelfSubjectAccessReviewSpec{
227+
ResourceAttributes: &authv1.ResourceAttributes{
228+
Group: group,
229+
Resource: resource,
230+
Verb: verb,
231+
Namespace: namespace,
232+
},
233+
},
234+
}
235+
236+
resp, err := kc.Client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{})
237+
if err != nil {
238+
kc.Logger.Error("failed to perform access review", "error", err)
239+
return false, fmt.Errorf("failed to perform access review: %w", err)
240+
}
241+
242+
return resp.Status.Allowed, nil
243+
}
244+
221245
func (kc *InternalKubernetesClient) GetUser(identity *RequestIdentity) (string, error) {
222246
// On internal client, we can use the identity from request directly
223247
return identity.UserID, nil

packages/maas/bff/internal/integrations/kubernetes/token_k8s_client.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ func (kc *TokenKubernetesClient) IsClusterAdmin(_ *RequestIdentity) (bool, error
5454
return true, nil
5555
}
5656

57-
// newTokenKubernetesClient creates a Kubernetes client using a user bearer token.
58-
func newTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClientInterface, error) {
57+
// NewTokenKubernetesClient creates a Kubernetes client using a user bearer token.
58+
func NewTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClientInterface, error) {
5959
baseConfig, err := helper.GetKubeconfig()
6060
if err != nil {
6161
logger.Error("failed to get kubeconfig", "error", err)
@@ -171,6 +171,30 @@ func (kc *TokenKubernetesClient) GetNamespaces(ctx context.Context, _ *RequestId
171171
return nsList.Items, nil
172172
}
173173

174+
func (kc *TokenKubernetesClient) CheckSelfAccess(ctx context.Context, group, resource, verb, namespace string) (bool, error) {
175+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
176+
defer cancel()
177+
178+
sar := &authv1.SelfSubjectAccessReview{
179+
Spec: authv1.SelfSubjectAccessReviewSpec{
180+
ResourceAttributes: &authv1.ResourceAttributes{
181+
Group: group,
182+
Resource: resource,
183+
Verb: verb,
184+
Namespace: namespace,
185+
},
186+
},
187+
}
188+
189+
resp, err := kc.Client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{})
190+
if err != nil {
191+
kc.Logger.Error("failed to perform access review", "error", err)
192+
return false, fmt.Errorf("failed to perform access review: %w", err)
193+
}
194+
195+
return resp.Status.Allowed, nil
196+
}
197+
174198
func (kc *TokenKubernetesClient) GetUser(_ *RequestIdentity) (string, error) {
175199
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
176200
defer cancel()

packages/maas/frontend/config/webpack.dev.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ const getProxyHeaders = () => {
4646
.trim();
4747
console.info('Logged in as user:', username);
4848
return {
49-
Authorization: `Bearer ${token}`,
5049
'x-forwarded-access-token': token,
5150
};
5251
} catch (error) {

packages/maas/frontend/src/app/api/k8s.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,22 @@ export const getNamespaces =
3131
}
3232
throw new Error('Invalid response format');
3333
});
34+
35+
type IsMaasAdminResult = {
36+
allowed: boolean;
37+
};
38+
39+
const isIsMaasAdminResult = (v: unknown): v is IsMaasAdminResult =>
40+
!!v && typeof v === 'object' && typeof (v as Record<string, unknown>).allowed === 'boolean';
41+
42+
export const getIsMaasAdmin =
43+
(hostPath = '') =>
44+
(opts: APIOptions): Promise<IsMaasAdminResult> =>
45+
handleRestFailures(
46+
restGET(hostPath, `${URL_PREFIX}/api/${BFF_API_VERSION}/is-maas-admin`, {}, opts),
47+
).then((response) => {
48+
if (isModArchResponse<unknown>(response) && isIsMaasAdminResult(response.data)) {
49+
return response.data;
50+
}
51+
throw new Error('Invalid response format');
52+
});

0 commit comments

Comments
 (0)