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:
Patrik Oldsberg
2023-03-21 14:24:54 +01:00
committed by GitHub
19 changed files with 336 additions and 34 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -75,6 +75,7 @@ export default async function createPlugin(
logger: env.logger,
config: env.config,
catalogApi,
permissions: env.permissions,
}).build();
return router;
}
+69 -10
View File
@@ -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 -1
View File
@@ -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)
+2
View File
@@ -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());
+7
View File
@@ -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)
+1
View File
@@ -39,6 +39,7 @@
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@kubernetes/client-node": "0.18.1"
},
"devDependencies": {
+4
View File
@@ -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];
+3
View File
@@ -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