diff --git a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx index 1e8bb0d494..7ed804466d 100644 --- a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx +++ b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx @@ -94,7 +94,7 @@ export const OidcAuth = () => { return ( - + Please read the{' '} diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx index 2aef86c5cd..07361bdd7c 100644 --- a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx +++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx @@ -10,15 +10,22 @@ import useAuthSettingsApi, { } from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; +import { useAdminCount } from 'hooks/api/getters/useAdminCount/useAdminCount'; +import { Link } from 'react-router-dom'; +import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens'; +import { PasswordAuthDialog } from './PasswordAuthDialog'; export const PasswordAuth = () => { const { setToastData, setToastApiError } = useToast(); - const { config } = useAuthSettings('simple'); + const { config, refetch } = useAuthSettings('simple'); const [disablePasswordAuth, setDisablePasswordAuth] = useState(false); const { updateSettings, errors, loading } = useAuthSettingsApi('simple'); const { hasAccess } = useContext(AccessContext); + const [confirmationOpen, setConfirmationOpen] = useState(false); + const { data: adminCount } = useAdminCount(); + const { tokens } = useApiTokens(); useEffect(() => { setDisablePasswordAuth(!!config.disabled); @@ -39,11 +46,20 @@ export const PasswordAuth = () => { const onSubmit = async (event: React.SyntheticEvent) => { event.preventDefault(); + if (!config.disabled && disablePasswordAuth) { + setConfirmationOpen(true); + } else { + onConfirm(); + } + }; + + const onConfirm = async () => { try { const settings: ISimpleAuthSettings = { disabled: disablePasswordAuth, }; await updateSettings(settings); + refetch(); setToastData({ title: 'Successfully saved', text: 'Password authentication settings stored.', @@ -56,9 +72,30 @@ export const PasswordAuth = () => { setDisablePasswordAuth(config.disabled); } }; + return (
+ + Overview of administrators on your Unleash instance: +
+
+ Password based administrators: {' '} + {adminCount?.password} +
+ Other administrators: {' '} + {adminCount?.noPassword} +
+ Admin service accounts: {' '} + + {adminCount?.service} + +
+ Admin API tokens: {' '} + + {tokens.filter(({ type }) => type === 'admin').length} + +
Password based login @@ -97,6 +134,16 @@ export const PasswordAuth = () => {

+ { + setConfirmationOpen(false); + onConfirm(); + }} + adminCount={adminCount!} + tokens={tokens} + />
); diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx new file mode 100644 index 0000000000..99544c4526 --- /dev/null +++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuthDialog.tsx @@ -0,0 +1,53 @@ +import { Alert, Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { IAdminCount } from 'hooks/api/getters/useAdminCount/useAdminCount'; +import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens'; + +interface IPasswordAuthDialogProps { + open: boolean; + setOpen: (open: boolean) => void; + onClick: () => void; + adminCount: IAdminCount; + tokens: IApiToken[]; +} + +export const PasswordAuthDialog = ({ + open, + setOpen, + onClick, + adminCount, + tokens, +}: IPasswordAuthDialogProps) => ( + { + setOpen(false); + }} + onClick={onClick} + title="Disable password based login?" + primaryButtonText="Disable password based login" + secondaryButtonText="Cancel" + > + + Warning! Disabling password based login may lock + you out of the system permanently if you do not have any alternative + admin credentials (such as an admin SSO account or admin API token) + secured beforehand. +
+
+ Password based administrators: {' '} + {adminCount?.password} +
+ Other administrators: {adminCount?.noPassword} +
+ Admin service accounts: {adminCount?.service} +
+ Admin API tokens: {' '} + {tokens.filter(({ type }) => type === 'admin').length} +
+ + You are about to disable password based login. Are you sure you want + to proceed? + +
+); diff --git a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx index dcaaa5c6e5..a7a4a8a58d 100644 --- a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx +++ b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx @@ -85,7 +85,7 @@ export const SamlAuth = () => { return ( - + Please read the{' '} diff --git a/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts b/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts new file mode 100644 index 0000000000..75bb1f9d75 --- /dev/null +++ b/frontend/src/hooks/api/getters/useAdminCount/useAdminCount.ts @@ -0,0 +1,29 @@ +import useSWR from 'swr'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; + +export interface IAdminCount { + password: number; + noPassword: number; + service: number; +} + +export const useAdminCount = () => { + const { data, error, mutate } = useSWR( + formatApiPath(`api/admin/user-admin/admin-count`), + fetcher + ); + + return { + data, + loading: !error && !data, + refetch: () => mutate(), + error, + }; +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Admin count')) + .then(res => res.json()); +}; diff --git a/src/lib/db/account-store.ts b/src/lib/db/account-store.ts index d8de4dba18..658a459f81 100644 --- a/src/lib/db/account-store.ts +++ b/src/lib/db/account-store.ts @@ -3,6 +3,7 @@ import User from '../types/user'; import NotFoundError from '../error/notfound-error'; import { IUserLookup } from '../types/stores/user-store'; +import { IAdminCount } from '../types/stores/account-store'; import { IAccountStore } from '../types'; import { Db } from './db'; @@ -169,4 +170,31 @@ export class AccountStore implements IAccountStore { this.logger.error('Could not update lastSeen, error: ', err); } } + + async getAdminCount(): Promise { + const adminCount = await this.activeAccounts() + .join('role_user as ru', 'users.id', 'ru.user_id') + .where( + 'ru.role_id', + '=', + this.db.raw('(SELECT id FROM roles WHERE name = ?)', ['Admin']), + ) + .select( + this.db.raw( + 'COUNT(CASE WHEN users.password_hash IS NOT NULL AND users.is_service = false THEN 1 END)::integer AS password', + ), + this.db.raw( + 'COUNT(CASE WHEN users.password_hash IS NULL AND users.is_service = false THEN 1 END)::integer AS no_password', + ), + this.db.raw( + 'COUNT(CASE WHEN users.is_service = true THEN 1 END)::integer AS service', + ), + ); + + return { + password: adminCount[0].password, + noPassword: adminCount[0].no_password, + service: adminCount[0].service, + }; + } } diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index ad87046618..b919c91c56 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -5,6 +5,7 @@ import { addonSchema, addonsSchema, addonTypeSchema, + adminCountSchema, adminFeaturesQuerySchema, apiTokenSchema, apiTokensSchema, @@ -182,6 +183,7 @@ interface OpenAPIV3DocumentWithServers extends OpenAPIV3.Document { // All schemas in `openapi/spec` should be listed here. export const schemas: UnleashSchemas = { + adminCountSchema, adminFeaturesQuerySchema, addonParameterSchema, addonSchema, diff --git a/src/lib/openapi/spec/admin-count-schema.ts b/src/lib/openapi/spec/admin-count-schema.ts new file mode 100644 index 0000000000..7861f37281 --- /dev/null +++ b/src/lib/openapi/spec/admin-count-schema.ts @@ -0,0 +1,28 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const adminCountSchema = { + $id: '#/components/schemas/adminCountSchema', + type: 'object', + additionalProperties: false, + description: 'Contains total admin counts for an Unleash instance.', + required: ['password', 'noPassword', 'service'], + properties: { + password: { + type: 'number', + description: 'Total number of admins that have a password set.', + }, + noPassword: { + type: 'number', + description: + 'Total number of admins that do not have a password set. May be SSO, but may also be users that did not set a password yet.', + }, + service: { + type: 'number', + description: + 'Total number of service accounts that have the admin root role.', + }, + }, + components: {}, +} as const; + +export type AdminCountSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 3cb523723f..e5793bcee8 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -136,3 +136,4 @@ export * from './upsert-segment-schema'; export * from './batch-features-schema'; export * from './token-string-list-schema'; export * from './bulk-toggle-features-schema'; +export * from './admin-count-schema'; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 611041c571..2a8dec5c33 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -41,6 +41,10 @@ import { IGroup } from '../../types/group'; import { IFlagResolver } from '../../types/experimental'; import rateLimit from 'express-rate-limit'; import { minutesToMilliseconds } from 'date-fns'; +import { + AdminCountSchema, + adminCountSchema, +} from '../../openapi/spec/admin-count-schema'; export default class UserAdminController extends Controller { private flagResolver: IFlagResolver; @@ -192,6 +196,22 @@ export default class UserAdminController extends Controller { ], }); + this.route({ + method: 'get', + path: '/admin-count', + handler: this.getAdminCount, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Users'], + operationId: 'getAdminCount', + responses: { + 200: createResponseSchema('adminCountSchema'), + }, + }), + ], + }); + this.route({ method: 'post', path: '', @@ -498,4 +518,19 @@ export default class UserAdminController extends Controller { await this.userService.changePassword(+id, password); res.status(200).send(); } + + async getAdminCount( + req: Request, + res: Response, + ): Promise { + console.log('user-admin controller'); + const adminCount = await this.accountService.getAdminCount(); + + this.openApiService.respondWithValidation( + 200, + res, + adminCountSchema.$id, + adminCount, + ); + } } diff --git a/src/lib/services/account-service.ts b/src/lib/services/account-service.ts index 8204493f99..9dc145c869 100644 --- a/src/lib/services/account-service.ts +++ b/src/lib/services/account-service.ts @@ -5,6 +5,7 @@ import { IAccountStore, IUnleashStores } from '../types/stores'; import { minutesToMilliseconds } from 'date-fns'; import { AccessService } from './access-service'; import { RoleName } from '../types/model'; +import { IAdminCount } from 'lib/types/stores/account-store'; interface IUserWithRole extends IUser { rootRole: number; @@ -52,6 +53,10 @@ export class AccountService { return this.store.getAccountByPersonalAccessToken(secret); } + async getAdminCount(): Promise { + return this.store.getAdminCount(); + } + async updateLastSeen(): Promise { if (this.lastSeenSecrets.size > 0) { const toStore = [...this.lastSeenSecrets]; diff --git a/src/lib/types/stores/account-store.ts b/src/lib/types/stores/account-store.ts index 4501109915..cfb754efac 100644 --- a/src/lib/types/stores/account-store.ts +++ b/src/lib/types/stores/account-store.ts @@ -7,6 +7,12 @@ export interface IUserLookup { email?: string; } +export interface IAdminCount { + password: number; + noPassword: number; + service: number; +} + export interface IAccountStore extends Store { hasAccount(idQuery: IUserLookup): Promise; search(query: string): Promise; @@ -15,4 +21,5 @@ export interface IAccountStore extends Store { count(): Promise; getAccountByPersonalAccessToken(secret: string): Promise; markSeenAt(secrets: string[]): Promise; + getAdminCount(): Promise; } diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 081499c35a..1c53163698 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -608,6 +608,30 @@ The provider you choose for your addon dictates what properties the \`parameters ], "type": "object", }, + "adminCountSchema": { + "additionalProperties": false, + "description": "Contains total admin counts for an Unleash instance.", + "properties": { + "noPassword": { + "description": "Total number of admins that do not have a password set. May be SSO, but may also be users that did not set a password yet.", + "type": "number", + }, + "password": { + "description": "Total number of admins that have a password set.", + "type": "number", + }, + "service": { + "description": "Total number of service accounts that have the admin root role.", + "type": "number", + }, + }, + "required": [ + "password", + "noPassword", + "service", + ], + "type": "object", + }, "adminFeaturesQuerySchema": { "additionalProperties": false, "properties": { @@ -12522,6 +12546,26 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/user-admin/admin-count": { + "get": { + "operationId": "getAdminCount", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/adminCountSchema", + }, + }, + }, + "description": "adminCountSchema", + }, + }, + "tags": [ + "Users", + ], + }, + }, "/api/admin/user-admin/reset-password": { "post": { "operationId": "resetUserPassword", diff --git a/src/test/fixtures/fake-account-store.ts b/src/test/fixtures/fake-account-store.ts index 284a797c0c..3b0c99471b 100644 --- a/src/test/fixtures/fake-account-store.ts +++ b/src/test/fixtures/fake-account-store.ts @@ -3,6 +3,7 @@ import { // ICreateUser, IUserLookup, IAccountStore, + IAdminCount, } from '../../lib/types/stores/account-store'; export class FakeAccountStore implements IAccountStore { @@ -93,4 +94,8 @@ export class FakeAccountStore implements IAccountStore { async markSeenAt(secrets: string[]): Promise { throw new Error('Not implemented'); } + + async getAdminCount(): Promise { + throw new Error('Not implemented'); + } }