Allow for Kubernetes cluster authentication via google auth (#2691)
* Allow for Kubernetes cluster authentication via google auth
- For the kubernetes and kubernetes-backend plugins
- The kubernetes (front-end) plugin uses the googleAuthApi and goes
through oauth flow to fetch a Google auth token for the current user
and pass that in a request to the kubernetes-backend plugin, which
uses this token as the service account token (and hence as the
authentication token for making K8s API requests).
- Related to https://github.com/spotify/backstage/issues/2552
* Use KubernetesAuthProvider and KubernetesAuthTranslator interfaces for K8s auth
- Implementations of KubernetesAuthProvider decorate the bodies of requests (for
Kubernetes resources) sent from the kubernetes plugin (frontend) to the
kubernetes-backend plugin (backend) with whatever information is
needed for K8s authentication
- Implementations of KubernetesAuthTranslator take the contents of these request
bodies sent from the kubernetes plugin (frontend) to the kubernetes-backend plugin
(backend) and use specific values in the bodies to properly set up tokens for K8s auth
- Start with KubernetesAuthProvider + KubernetesAuthTranslator
implementations for 'serviceAccount' and 'google' as auth providers
- Implementation of what was proposed at https://github.com/spotify/backstage/issues/2552#issuecomment-702545382
- Load in and prepare KubernetesAuthProvider implementations at plugin
startup time via KubernetesAuthProviders API (that essentially stores
or wraps these KubernetesAuthProvider instances)
- Related to https://github.com/spotify/backstage/issues/2552
This commit is contained in:
+8
-3
@@ -43,6 +43,7 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -60,6 +61,7 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -70,13 +72,14 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
clusters: [
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
serviceAccountToken: 'token',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8081',
|
||||
authProvider: 'google',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -92,13 +95,15 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
serviceAccountToken: 'token',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8081',
|
||||
authProvider: 'google',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -28,12 +28,15 @@ export class MultiTenantConfigClusterLocator
|
||||
}
|
||||
|
||||
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
|
||||
// TODO: Add validation that authProvider is required and serviceAccountToken
|
||||
// is required if authProvider is serviceAccount
|
||||
return new MultiTenantConfigClusterLocator(
|
||||
config.map(c => {
|
||||
return {
|
||||
name: c.getString('name'),
|
||||
url: c.getString('url'),
|
||||
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
|
||||
authProvider: c.getString('authProvider'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
|
||||
export type AuthProviderType = 'google' | 'serviceAccount';
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { KubernetesAuthTranslator } from './types';
|
||||
import { AuthRequestBody, ClusterDetails } from '../types/types';
|
||||
|
||||
export class GoogleKubernetesAuthTranslator
|
||||
implements KubernetesAuthTranslator {
|
||||
async decorateClusterDetailsWithAuth(
|
||||
clusterDetails: ClusterDetails,
|
||||
requestBody: AuthRequestBody,
|
||||
): Promise<ClusterDetails> {
|
||||
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
|
||||
{},
|
||||
clusterDetails,
|
||||
);
|
||||
const authToken: string | undefined = requestBody.auth?.google;
|
||||
|
||||
if (authToken) {
|
||||
clusterDetailsWithAuthToken.serviceAccountToken = authToken;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Google token not found under auth.google in request body',
|
||||
);
|
||||
}
|
||||
return clusterDetailsWithAuthToken;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { KubernetesAuthTranslator } from './types';
|
||||
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
|
||||
import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
|
||||
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
|
||||
|
||||
describe('getKubernetesAuthTranslatorInstance', () => {
|
||||
const sut = KubernetesAuthTranslatorGenerator;
|
||||
|
||||
it('can return an auth translator for google auth', () => {
|
||||
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
|
||||
'google',
|
||||
);
|
||||
expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true);
|
||||
});
|
||||
|
||||
it('can return an auth translator for serviceAccount auth', () => {
|
||||
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
|
||||
'serviceAccount',
|
||||
);
|
||||
expect(
|
||||
authTranslator instanceof ServiceAccountKubernetesAuthTranslator,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
|
||||
expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow(
|
||||
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
|
||||
);
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { KubernetesAuthTranslator } from './types';
|
||||
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
|
||||
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
|
||||
|
||||
export class KubernetesAuthTranslatorGenerator {
|
||||
static getKubernetesAuthTranslatorInstance(
|
||||
authProvider: String,
|
||||
): KubernetesAuthTranslator {
|
||||
switch (authProvider) {
|
||||
case 'google': {
|
||||
return new GoogleKubernetesAuthTranslator();
|
||||
}
|
||||
case 'serviceAccount': {
|
||||
return new ServiceAccountKubernetesAuthTranslator();
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { KubernetesAuthTranslator } from './types';
|
||||
import { AuthRequestBody, ClusterDetails } from '../types/types';
|
||||
|
||||
export class ServiceAccountKubernetesAuthTranslator
|
||||
implements KubernetesAuthTranslator {
|
||||
async decorateClusterDetailsWithAuth(
|
||||
clusterDetails: ClusterDetails,
|
||||
// To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read.
|
||||
// @ts-ignore-start
|
||||
requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// @ts-ignore-end
|
||||
): Promise<ClusterDetails> {
|
||||
return clusterDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { AuthRequestBody, ClusterDetails } from '../types/types';
|
||||
|
||||
export interface KubernetesAuthTranslator {
|
||||
decorateClusterDetailsWithAuth(
|
||||
clusterDetails: ClusterDetails,
|
||||
requestBody: AuthRequestBody,
|
||||
): Promise<ClusterDetails>;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ describe('KubernetesClientProvider', () => {
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
expect(result.basePath).toBe('http://localhost:9999');
|
||||
@@ -55,12 +56,14 @@ describe('KubernetesClientProvider', () => {
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
const result2 = sut.getCoreClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
expect(result1.basePath).toBe('http://localhost:9999');
|
||||
@@ -89,6 +92,7 @@ describe('KubernetesClientProvider', () => {
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
expect(result.basePath).toBe('http://localhost:9999');
|
||||
@@ -111,12 +115,14 @@ describe('KubernetesClientProvider', () => {
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
const result2 = sut.getAppsClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
authProvider: 'serviceAccount',
|
||||
});
|
||||
|
||||
expect(result1.basePath).toBe('http://localhost:9999');
|
||||
|
||||
@@ -62,7 +62,8 @@ describe('KubernetesClientProvider', () => {
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: undefined,
|
||||
serviceAccountToken: 'token',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
new Set(['pods', 'services']),
|
||||
);
|
||||
@@ -124,7 +125,8 @@ describe('KubernetesClientProvider', () => {
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: undefined,
|
||||
serviceAccountToken: 'token',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
new Set(['pods', 'services']),
|
||||
);
|
||||
@@ -172,7 +174,8 @@ describe('KubernetesClientProvider', () => {
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: undefined,
|
||||
serviceAccountToken: 'token',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
new Set<any>(['foo']),
|
||||
),
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'test-cluster',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -89,6 +90,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
|
||||
getClusterByServiceId,
|
||||
},
|
||||
getVoidLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getClusterByServiceId.mock.calls.length).toBe(1);
|
||||
@@ -142,9 +144,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'test-cluster',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
name: 'other-cluster',
|
||||
authProvider: 'google',
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -160,6 +164,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
|
||||
getClusterByServiceId,
|
||||
},
|
||||
getVoidLogger(),
|
||||
{
|
||||
auth: {
|
||||
google: 'google_token_123',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getClusterByServiceId.mock.calls.length).toBe(1);
|
||||
|
||||
@@ -16,17 +16,22 @@
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
AuthRequestBody,
|
||||
ClusterDetails,
|
||||
KubernetesClusterLocator,
|
||||
KubernetesFetcher,
|
||||
KubernetesObjectTypes,
|
||||
ObjectsByServiceIdResponse,
|
||||
} from '..';
|
||||
} from '../types/types';
|
||||
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
|
||||
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
|
||||
|
||||
export type GetKubernetesObjectsByServiceIdHandler = (
|
||||
serviceId: string,
|
||||
fetcher: KubernetesFetcher,
|
||||
clusterLocator: KubernetesClusterLocator,
|
||||
logger: Logger,
|
||||
requestBody: AuthRequestBody,
|
||||
objectsToFetch?: Set<KubernetesObjectTypes>,
|
||||
) => Promise<ObjectsByServiceIdResponse>;
|
||||
|
||||
@@ -46,18 +51,35 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic
|
||||
fetcher,
|
||||
clusterLocator,
|
||||
logger,
|
||||
requestBody,
|
||||
objectsToFetch = DEFAULT_OBJECTS,
|
||||
) => {
|
||||
const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
|
||||
const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId(
|
||||
serviceId,
|
||||
);
|
||||
|
||||
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
|
||||
const promises: Promise<ClusterDetails>[] = clusterDetails.map(cd => {
|
||||
const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
|
||||
cd.authProvider,
|
||||
);
|
||||
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
|
||||
cd,
|
||||
requestBody,
|
||||
);
|
||||
});
|
||||
const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all(
|
||||
promises,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`serviceId=${serviceId} clusterDetails=[${clusterDetails
|
||||
`serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth
|
||||
.map(c => c.name)
|
||||
.join(', ')}]`,
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
clusterDetails.map(cd => {
|
||||
clusterDetailsDecoratedForAuth.map(cd => {
|
||||
return fetcher
|
||||
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
|
||||
.then(result => {
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('router', () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /services/:serviceId', () => {
|
||||
it('happy path: lists kubernetes objects', async () => {
|
||||
describe('post /services/:serviceId', () => {
|
||||
it('happy path: lists kubernetes objects without auth in request body', async () => {
|
||||
const result = {
|
||||
clusterOne: {
|
||||
pods: [
|
||||
@@ -69,7 +69,34 @@ describe('router', () => {
|
||||
} as any;
|
||||
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
|
||||
|
||||
const response = await request(app).get('/services/test-service');
|
||||
const response = await request(app).post('/services/test-service');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(result);
|
||||
});
|
||||
|
||||
it('happy path: lists kubernetes objects with auth in request body', async () => {
|
||||
const result = {
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/services/test-service')
|
||||
.send({
|
||||
auth: {
|
||||
google: 'google_token_123',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(result);
|
||||
@@ -78,7 +105,7 @@ describe('router', () => {
|
||||
it('internal error: lists kubernetes objects', async () => {
|
||||
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
|
||||
|
||||
const response = await request(app).get('/services/test-service');
|
||||
const response = await request(app).post('/services/test-service');
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual({ error: 'some internal error' });
|
||||
|
||||
@@ -26,7 +26,11 @@ import {
|
||||
GetKubernetesObjectsByServiceIdHandler,
|
||||
handleGetKubernetesObjectsByServiceId,
|
||||
} from './getKubernetesObjectsByServiceIdHandler';
|
||||
import { KubernetesClusterLocator, KubernetesFetcher } from '..';
|
||||
import {
|
||||
AuthRequestBody,
|
||||
KubernetesClusterLocator,
|
||||
KubernetesFetcher,
|
||||
} from '../types/types';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
@@ -62,15 +66,16 @@ export const makeRouter = (
|
||||
router.use(express.json());
|
||||
|
||||
// TODO error handling
|
||||
router.get('/services/:serviceId', async (req, res) => {
|
||||
router.post('/services/:serviceId', async (req, res) => {
|
||||
const serviceId = req.params.serviceId;
|
||||
|
||||
const requestBody: AuthRequestBody = req.body;
|
||||
try {
|
||||
const response = await handleGetByServiceId(
|
||||
serviceId,
|
||||
fetcher,
|
||||
clusterLocator,
|
||||
logger,
|
||||
requestBody,
|
||||
);
|
||||
res.send(response);
|
||||
} catch (e) {
|
||||
|
||||
@@ -27,8 +27,14 @@ import {
|
||||
export interface ClusterDetails {
|
||||
name: string;
|
||||
url: string;
|
||||
// TODO this will eventually be configured by the auth translation work
|
||||
serviceAccountToken: string | undefined;
|
||||
authProvider: string;
|
||||
serviceAccountToken?: string | undefined;
|
||||
}
|
||||
|
||||
export interface AuthRequestBody {
|
||||
auth?: {
|
||||
google?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClusterObjects {
|
||||
|
||||
Reference in New Issue
Block a user