Merge pull request #16237 from RubenV-dev/proxy-restrict
Feature: Enable the Kubernetes Proxy Endpoint to be disabled via PermissionPolicy
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': minor
|
||||
---
|
||||
|
||||
**BREAKING**: `KubernetesBuilder.create` now requires a `permissions` field of type `PermissionEvaluator`. The kubernetes `/proxy` endpoint now requires two tokens: the `Backstage-Kubernetes-Authorization` header should contain a bearer token for the target cluster, and the `Authorization` header should contain a backstage identity token. The kubernetes `/proxy` endpoint now requires a `Backstage-Kubernetes-Cluster` header replacing the previously required `X-Kubernetes-Cluster` header.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-common': patch
|
||||
---
|
||||
|
||||
Introduced proxy permission types to be used with the kubernetes proxy endpoint's permission framework integration.
|
||||
@@ -75,6 +75,7 @@ export default async function createPlugin(
|
||||
logger: env.logger,
|
||||
config: env.config,
|
||||
catalogApi,
|
||||
permissions: env.permissions,
|
||||
}).build();
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
discoveryApiRef,
|
||||
googleAuthApiRef,
|
||||
useApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
const CLUSTER_NAME = ''; // use a known cluster name
|
||||
@@ -32,6 +33,10 @@ const token = await googleAuthApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
);
|
||||
|
||||
// get a backstage ID token
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const { token: userToken } = await identityApi.getCredentials();
|
||||
|
||||
const discoveryApi = useApi(discoveryApiRef);
|
||||
const kubernetesBaseUrl = await discoveryApi.getBaseUrl('kubernetes');
|
||||
const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`;
|
||||
@@ -40,8 +45,9 @@ const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`;
|
||||
await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Kubernetes-Cluster': CLUSTER_NAME,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Backstage-Kubernetes-Cluster': CLUSTER_NAME,
|
||||
'Backstage-Kubernetes-Authorization': `Bearer ${token}`,
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -49,16 +55,19 @@ await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, {
|
||||
## How it works
|
||||
|
||||
The proxy will interpret the
|
||||
[`X-Kubernetes-Cluster`
|
||||
header](https://backstage.io/docs/reference/plugin-kubernetes-backend.header_kubernetes_cluster)
|
||||
as the name of the cluster to target. This name will be compared to each cluster
|
||||
[`Backstage-Kubernetes-Cluster`](https://backstage.io/docs/reference/plugin-kubernetes-backend.header_kubernetes_cluster)
|
||||
header as the name of the cluster to target. This name will be compared to each cluster
|
||||
returned by all the configured [cluster
|
||||
locators](https://backstage.io/docs/features/kubernetes/configuration#clusterlocatormethods)
|
||||
-- the first cluster whose [`name` field](https://backstage.io/docs/features/kubernetes/configuration#clustersname) matches
|
||||
the value in the header will be targeted.
|
||||
|
||||
Then the request will be forwarded verbatim (but with the endpoint's base URL
|
||||
prefix stripped) to the cluster.
|
||||
Then the request will be forwarded to the cluster.
|
||||
|
||||
Overall, the only changes to each request are:
|
||||
|
||||
- the endpoint's base URL prefix is stripped.
|
||||
- the `Backstage-Kubernetes-Authorization` header becomes the `Authorization` header that is used when forwarding the request.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -73,9 +82,59 @@ providers](https://backstage.io/docs/features/kubernetes/authentication#client-s
|
||||
The proxy has no provisions for mTLS, so it cannot be used to connect to
|
||||
clusters using the [x509 Client
|
||||
Certs](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#x509-client-certs)
|
||||
authentication strategy. [Bearer
|
||||
tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#putting-a-bearer-token-in-a-request)
|
||||
will be forwarded as-is.
|
||||
authentication strategy.\
|
||||
The current `/proxy` Implementation expects a
|
||||
[Bearer
|
||||
token](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#putting-a-bearer-token-in-a-request)
|
||||
to be provided as a `Backstage-Kubernetes-Authorization` header for a target cluster. This token will be used as the `Authorization` header when forwarding a request to a target cluster.
|
||||
|
||||
## How to disable the proxy endpoint via PermissionPolicy
|
||||
|
||||
The kubernetes plugin can disable the use of the `proxy` endpoint by leveraging the permission framework. This integration allows admins to use well defined PermissionPolicies to restrict the use of the endpoint all together. The `proxy` endpoint can return 403 errors even if it has a valid ID token attached that a cluster would authorize thus allowing integrators the confidence that Backstage is not accessing kubernetes clusters on behalf of undesired parties.
|
||||
|
||||
This feature assumes your backstage instance has enabled the [permissions framework](https://backstage.io/docs/permissions/getting-started)
|
||||
|
||||
A sample policy like:
|
||||
|
||||
[packages/backend/src/plugins/permissions.ts](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/permission.ts)
|
||||
|
||||
```typescript
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PolicyDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionPolicy,
|
||||
PolicyQuery,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
|
||||
class KubernetesDenyAllProxyEndpointPolicy implements PermissionPolicy {
|
||||
async handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision> {
|
||||
if (request.permission.name === 'kubernetes.proxy') {
|
||||
return {
|
||||
result: AuthorizeResult.DENY,
|
||||
};
|
||||
}
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
would leverage the permission framework to return the following response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"name": "NotAllowedError"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
even if a valid ID token was attached that a cluster would authorize.
|
||||
|
||||
## Other known limitations
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default async function createPlugin(
|
||||
logger: env.logger,
|
||||
config: env.config,
|
||||
catalogApi,
|
||||
permissions: env.permissions,
|
||||
}).build();
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
|
||||
import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
|
||||
import { Logger } from 'winston';
|
||||
import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import type { RequestHandler } from 'express';
|
||||
import { TokenCredential } from '@azure/identity';
|
||||
@@ -141,6 +142,9 @@ export class GoogleServiceAccountAuthTranslator
|
||||
): Promise<GKEClusterDetails>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const HEADER_KUBERNETES_AUTH: string;
|
||||
|
||||
// @public
|
||||
export const HEADER_KUBERNETES_CLUSTER: string;
|
||||
|
||||
@@ -200,6 +204,7 @@ export class KubernetesBuilder {
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
proxy: KubernetesProxy,
|
||||
permissionApi: PermissionEvaluator,
|
||||
): express.Router;
|
||||
// (undocumented)
|
||||
protected buildServiceLocator(
|
||||
@@ -271,6 +276,8 @@ export interface KubernetesEnvironment {
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -340,9 +347,16 @@ export type KubernetesObjectTypes =
|
||||
export class KubernetesProxy {
|
||||
constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier);
|
||||
// (undocumented)
|
||||
createRequestHandler(): RequestHandler;
|
||||
createRequestHandler(
|
||||
options: KubernetesProxyCreateRequestHandlerOptions,
|
||||
): RequestHandler;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type KubernetesProxyCreateRequestHandlerOptions = {
|
||||
permissionApi: PermissionEvaluator;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface KubernetesServiceLocator {
|
||||
// (undocumented)
|
||||
@@ -418,6 +432,8 @@ export interface RouterOptions {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-node": "workspace:^",
|
||||
"@google-cloud/container": "^4.0.0",
|
||||
"@jest-mock/express": "^2.0.1",
|
||||
"@kubernetes/client-node": "0.18.1",
|
||||
|
||||
@@ -31,16 +31,24 @@ import {
|
||||
import { KubernetesBuilder } from './KubernetesBuilder';
|
||||
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { HEADER_KUBERNETES_CLUSTER } from './KubernetesProxy';
|
||||
import {
|
||||
HEADER_KUBERNETES_CLUSTER,
|
||||
HEADER_KUBERNETES_AUTH,
|
||||
} from './KubernetesProxy';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
describe('KubernetesBuilder', () => {
|
||||
let app: express.Express;
|
||||
let kubernetesFanOutHandler: jest.Mocked<KubernetesFanOutHandler>;
|
||||
let config: Config;
|
||||
let catalogApi: CatalogApi;
|
||||
let permissions: jest.Mocked<PermissionEvaluator>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = getVoidLogger();
|
||||
@@ -74,10 +82,16 @@ describe('KubernetesBuilder', () => {
|
||||
getKubernetesObjectsByEntity: jest.fn(),
|
||||
} as any;
|
||||
|
||||
permissions = {
|
||||
authorize: jest.fn(),
|
||||
authorizeConditional: jest.fn(),
|
||||
};
|
||||
|
||||
const { router } = await KubernetesBuilder.createBuilder({
|
||||
config,
|
||||
logger,
|
||||
catalogApi,
|
||||
permissions,
|
||||
})
|
||||
.setObjectsProvider(kubernetesFanOutHandler)
|
||||
.setClusterSupplier(clusterSupplier)
|
||||
@@ -250,6 +264,7 @@ describe('KubernetesBuilder', () => {
|
||||
logger,
|
||||
config,
|
||||
catalogApi,
|
||||
permissions,
|
||||
})
|
||||
.setClusterSupplier(clusterSupplier)
|
||||
.setServiceLocator(serviceLocator)
|
||||
@@ -271,27 +286,32 @@ describe('KubernetesBuilder', () => {
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('post /proxy', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.post('https://localhost:1234/api/v1/namespaces', (req, res, ctx) =>
|
||||
req
|
||||
.arrayBuffer()
|
||||
.then(body =>
|
||||
res(
|
||||
ctx.set('content-type', `${req.headers.get('content-type')}`),
|
||||
ctx.body(body),
|
||||
),
|
||||
),
|
||||
rest.post(
|
||||
'https://localhost:1234/api/v1/namespaces',
|
||||
(req, res, ctx) => {
|
||||
if (!req.headers.get('Authorization')) {
|
||||
return res(ctx.status(401));
|
||||
}
|
||||
return req
|
||||
.arrayBuffer()
|
||||
.then(body =>
|
||||
res(
|
||||
ctx.set('content-type', `${req.headers.get('content-type')}`),
|
||||
ctx.body(body),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the given request body', async () => {
|
||||
it('returns the given request body with permission set to allow', async () => {
|
||||
const requestBody = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
@@ -300,9 +320,14 @@ describe('KubernetesBuilder', () => {
|
||||
},
|
||||
};
|
||||
|
||||
permissions.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
const proxyEndpointRequest = request(app)
|
||||
.post('/proxy/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
|
||||
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
|
||||
.send(requestBody);
|
||||
|
||||
worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough()));
|
||||
@@ -312,7 +337,7 @@ describe('KubernetesBuilder', () => {
|
||||
expect(response.body).toStrictEqual(requestBody);
|
||||
});
|
||||
|
||||
it('supports yaml content type', async () => {
|
||||
it('supports yaml content type with permission set to allow', async () => {
|
||||
const requestBody = `---
|
||||
kind: Namespace
|
||||
apiVersion: v1
|
||||
@@ -320,9 +345,14 @@ metadata:
|
||||
name: new-ns
|
||||
`;
|
||||
|
||||
permissions.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
const proxyEndpointRequest = request(app)
|
||||
.post('/proxy/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
|
||||
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
|
||||
.set('content-type', 'application/yaml')
|
||||
.send(requestBody);
|
||||
|
||||
@@ -331,5 +361,50 @@ metadata:
|
||||
const response = await proxyEndpointRequest;
|
||||
expect(response.text).toEqual(requestBody);
|
||||
});
|
||||
|
||||
it('returns a 403 response if Permission Policy is in place that blocks endpoint', async () => {
|
||||
const requestBody = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'new-ns',
|
||||
},
|
||||
};
|
||||
|
||||
permissions.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.DENY }]),
|
||||
);
|
||||
|
||||
const proxyEndpointRequest = request(app)
|
||||
.post('/proxy/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
|
||||
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
|
||||
.send(requestBody);
|
||||
|
||||
worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough()));
|
||||
|
||||
const response = await proxyEndpointRequest;
|
||||
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe('get /.well-known/backstage/permissions/metadata', () => {
|
||||
it('lists permissions supported by the kubernetes plugin', async () => {
|
||||
const response = await request(app).get(
|
||||
'/.well-known/backstage/permissions/metadata',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toMatchObject({
|
||||
permissions: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'kubernetes.proxy',
|
||||
attributes: {},
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
*/
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { kubernetesPermissions } from '@backstage/plugin-kubernetes-common';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Duration } from 'luxon';
|
||||
@@ -49,6 +52,7 @@ export interface KubernetesEnvironment {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
catalogApi: CatalogApi;
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +93,7 @@ export class KubernetesBuilder {
|
||||
public async build(): KubernetesBuilderReturn {
|
||||
const logger = this.env.logger;
|
||||
const config = this.env.config;
|
||||
const permissions = this.env.permissions;
|
||||
|
||||
logger.info('Initializing Kubernetes backend');
|
||||
|
||||
@@ -126,6 +131,7 @@ export class KubernetesBuilder {
|
||||
clusterSupplier,
|
||||
this.env.catalogApi,
|
||||
proxy,
|
||||
permissions,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -262,12 +268,17 @@ export class KubernetesBuilder {
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
proxy: KubernetesProxy,
|
||||
permissionApi: PermissionEvaluator,
|
||||
): express.Router {
|
||||
const logger = this.env.logger;
|
||||
const router = Router();
|
||||
router.use('/proxy', proxy.createRequestHandler());
|
||||
router.use('/proxy', proxy.createRequestHandler({ permissionApi }));
|
||||
router.use(express.json());
|
||||
|
||||
router.use(
|
||||
createPermissionIntegrationRouter({
|
||||
permissions: kubernetesPermissions,
|
||||
}),
|
||||
);
|
||||
// @deprecated
|
||||
router.post('/services/:serviceId', async (req, res) => {
|
||||
const serviceId = req.params.serviceId;
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
HEADER_KUBERNETES_CLUSTER,
|
||||
KubernetesProxy,
|
||||
} from './KubernetesProxy';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
describe('KubernetesProxy', () => {
|
||||
let proxy: KubernetesProxy;
|
||||
@@ -64,6 +68,11 @@ describe('KubernetesProxy', () => {
|
||||
getClusters: jest.fn(),
|
||||
};
|
||||
|
||||
const permissionApi: jest.Mocked<PermissionEvaluator> = {
|
||||
authorize: jest.fn(),
|
||||
authorizeConditional: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier);
|
||||
@@ -71,13 +80,16 @@ describe('KubernetesProxy', () => {
|
||||
|
||||
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
|
||||
clusterSupplier.getClusters.mockResolvedValue([]);
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
const req = buildMockRequest('test', 'api');
|
||||
const { res, next } = getMockRes();
|
||||
|
||||
await expect(proxy.createRequestHandler()(req, res, next)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(
|
||||
proxy.createRequestHandler({ permissionApi })(req, res, next),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('should pass the exact response from Kubernetes', async () => {
|
||||
@@ -100,7 +112,13 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
const app = express().use('/mountpath', proxy.createRequestHandler());
|
||||
const app = express().use(
|
||||
'/mountpath',
|
||||
proxy.createRequestHandler({ permissionApi }),
|
||||
);
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
|
||||
|
||||
@@ -18,9 +18,16 @@ import {
|
||||
ErrorResponseBody,
|
||||
ForwardedError,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
PermissionEvaluator,
|
||||
AuthorizeResult,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { bufferFromFileOrString } from '@kubernetes/client-node';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
@@ -34,7 +41,24 @@ export const APPLICATION_JSON: string = 'application/json';
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster';
|
||||
export const HEADER_KUBERNETES_CLUSTER: string = 'Backstage-Kubernetes-Cluster';
|
||||
|
||||
/**
|
||||
* The header that is used to specify the Authentication Authorities token.
|
||||
* e.x if using the google auth provider as your authentication authority then this field would be the google provided bearer token.
|
||||
* @public
|
||||
*/
|
||||
export const HEADER_KUBERNETES_AUTH: string =
|
||||
'Backstage-Kubernetes-Authorization';
|
||||
|
||||
/**
|
||||
* The options object expected to be passed as a parameter to KubernetesProxy.createRequestHandler().
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type KubernetesProxyCreateRequestHandlerOptions = {
|
||||
permissionApi: PermissionEvaluator;
|
||||
};
|
||||
|
||||
/**
|
||||
* A proxy that routes requests to the Kubernetes API.
|
||||
@@ -49,8 +73,29 @@ export class KubernetesProxy {
|
||||
private readonly clusterSupplier: KubernetesClustersSupplier,
|
||||
) {}
|
||||
|
||||
public createRequestHandler(): RequestHandler {
|
||||
public createRequestHandler(
|
||||
options: KubernetesProxyCreateRequestHandlerOptions,
|
||||
): RequestHandler {
|
||||
const { permissionApi } = options;
|
||||
return async (req, res, next) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
const authorizeResponse = (
|
||||
await permissionApi.authorize(
|
||||
[{ permission: kubernetesProxyPermission }],
|
||||
{
|
||||
token,
|
||||
},
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (authorizeResponse.result === AuthorizeResult.DENY) {
|
||||
res.status(403).json({ error: new NotAllowedError('Unauthorized') });
|
||||
return;
|
||||
}
|
||||
|
||||
const middleware = await this.getMiddleware(req);
|
||||
middleware(req, res, next);
|
||||
};
|
||||
@@ -104,6 +149,11 @@ export class KubernetesProxy {
|
||||
|
||||
res.status(500).json(body);
|
||||
},
|
||||
onProxyReq: (proxyReq, req) => {
|
||||
// the kubernetes proxy endpoint expects a header field labeled `Backstage-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an bearer token for the target cluster.
|
||||
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
|
||||
proxyReq.setHeader('Authorization', token);
|
||||
},
|
||||
});
|
||||
|
||||
this.middlewareForClusterName.set(originalCluster.name, middleware);
|
||||
|
||||
@@ -16,5 +16,10 @@
|
||||
|
||||
export * from './KubernetesBuilder';
|
||||
export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler';
|
||||
export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy';
|
||||
export {
|
||||
HEADER_KUBERNETES_CLUSTER,
|
||||
KubernetesProxy,
|
||||
HEADER_KUBERNETES_AUTH,
|
||||
} from './KubernetesProxy';
|
||||
export type { KubernetesProxyCreateRequestHandlerOptions } from './KubernetesProxy';
|
||||
export * from './router';
|
||||
|
||||
@@ -21,6 +21,7 @@ import express from 'express';
|
||||
import { KubernetesBuilder } from './KubernetesBuilder';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -32,6 +33,7 @@ export interface RouterOptions {
|
||||
catalogApi: CatalogApi;
|
||||
clusterSupplier?: KubernetesClustersSupplier;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
@@ -47,6 +48,8 @@ export async function createStandaloneApplication(
|
||||
discoveryApi: SingleHostDiscovery.fromConfig(config),
|
||||
});
|
||||
|
||||
const permissions = {} as PermissionEvaluator;
|
||||
|
||||
app.use(helmet());
|
||||
if (enableCors) {
|
||||
app.use(cors());
|
||||
@@ -54,7 +57,10 @@ export async function createStandaloneApplication(
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/', await createRouter({ logger, config, discovery, catalogApi }));
|
||||
app.use(
|
||||
'/',
|
||||
await createRouter({ logger, config, discovery, catalogApi, permissions }),
|
||||
);
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BasicPermission } from '@backstage/plugin-permission-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import { PodStatus } from '@kubernetes/client-node';
|
||||
@@ -217,6 +218,12 @@ export type KubernetesErrorTypes =
|
||||
// @public (undocumented)
|
||||
export type KubernetesFetchError = StatusError | RawFetchError;
|
||||
|
||||
// @public
|
||||
export const kubernetesPermissions: BasicPermission[];
|
||||
|
||||
// @public
|
||||
export const kubernetesProxyPermission: BasicPermission;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface KubernetesRequestAuth {
|
||||
// (undocumented)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@kubernetes/client-node": "0.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -22,3 +22,7 @@
|
||||
|
||||
export * from './types';
|
||||
export * from './catalog-entity-constants';
|
||||
export {
|
||||
kubernetesProxyPermission,
|
||||
kubernetesPermissions,
|
||||
} from './permissions';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
/** This permission is used to check access to the proxy endpoint
|
||||
* @public
|
||||
*/
|
||||
export const kubernetesProxyPermission = createPermission({
|
||||
name: 'kubernetes.proxy',
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* List of all Kubernetes permissions.
|
||||
* @public
|
||||
*/
|
||||
export const kubernetesPermissions = [kubernetesProxyPermission];
|
||||
@@ -6763,6 +6763,8 @@ __metadata:
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/plugin-permission-node": "workspace:^"
|
||||
"@google-cloud/container": ^4.0.0
|
||||
"@jest-mock/express": ^2.0.1
|
||||
"@kubernetes/client-node": 0.18.1
|
||||
@@ -6799,6 +6801,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@kubernetes/client-node": 0.18.1
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
Reference in New Issue
Block a user