diff --git a/backend/src/db/migrations/20250317101525_add-instance-admin-mi.ts b/backend/src/db/migrations/20250317101525_add-instance-admin-mi.ts new file mode 100644 index 000000000..7646b48a9 --- /dev/null +++ b/backend/src/db/migrations/20250317101525_add-instance-admin-mi.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas/models"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SuperAdmin, "adminIdentityIds"))) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.specificType("adminIdentityIds", "text[]"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "adminIdentityIds")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("adminIdentityIds"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 2c0fd7dc4..01aac280b 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -25,7 +25,8 @@ export const SuperAdminSchema = z.object({ encryptedSlackClientId: zodBuffer.nullable().optional(), encryptedSlackClientSecret: zodBuffer.nullable().optional(), authConsentContent: z.string().nullable().optional(), - pageFrameContent: z.string().nullable().optional() + pageFrameContent: z.string().nullable().optional(), + adminIdentityIds: z.string().array().nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 9d239a405..806c5a21e 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -8,6 +8,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType, AuthMethod, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; export type TAuthMode = | { @@ -43,6 +44,7 @@ export type TAuthMode = identityName: string; orgId: string; authMethod: null; + isInstanceAdmin?: boolean; } | { authMode: AuthMode.SCIM_TOKEN; @@ -129,13 +131,15 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } case AuthMode.IDENTITY_ACCESS_TOKEN: { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const serverCfg = await getServerCfg(); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, orgId: identity.orgId, identityId: identity.identityId, identityName: identity.name, - authMethod: null + authMethod: null, + isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId) }; break; } diff --git a/backend/src/server/plugins/auth/superAdmin.ts b/backend/src/server/plugins/auth/superAdmin.ts index f5868f130..4ca9ba373 100644 --- a/backend/src/server/plugins/auth/superAdmin.ts +++ b/backend/src/server/plugins/auth/superAdmin.ts @@ -1,16 +1,18 @@ import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from "fastify"; import { ForbiddenRequestError } from "@app/lib/errors"; -import { ActorType } from "@app/services/auth/auth-type"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const verifySuperAdmin = ( req: T, _res: FastifyReply, done: HookHandlerDoneFunction ) => { - if (req.auth.actor !== ActorType.USER || !req.auth.user.superAdmin) - throw new ForbiddenRequestError({ - message: "Requires elevated super admin privileges" - }); - done(); + if (isSuperAdmin(req.auth)) { + return done(); + } + + throw new ForbiddenRequestError({ + message: "Requires elevated super admin privileges" + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b9f47cb7e..0f1303263 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -637,6 +637,9 @@ export const registerRoutes = async ( userDAL, identityDAL, userAliasDAL, + identityTokenAuthDAL, + identityAccessTokenDAL, + identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a1c433650..f46c2f2dd 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -98,7 +98,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT, AuthMode.API_KEY])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -139,7 +139,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -171,12 +171,16 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { identities: IdentitiesSchema.pick({ name: true, id: true - }).array() + }) + .extend({ + isInstanceAdmin: z.boolean() + }) + .array() }) } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -206,7 +210,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -240,7 +244,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -265,7 +269,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }) }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -293,7 +297,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -316,7 +320,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }) }, onRequest: (req, res, done) => { - verifyAuth([AuthMode.JWT])(req, res, () => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { verifySuperAdmin(req, res, done); }); }, @@ -394,4 +398,54 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/initialize", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + email: z.string().email().trim(), + password: z.string().trim(), + organizationName: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + user: UsersSchema, + organization: OrganizationsSchema, + machineIdentity: IdentitiesSchema.extend({ + credentials: z.object({ + token: z.string() + }) // would just be Token AUTH for now + }) + }) + } + }, + handler: async (req) => { + const { user, organization, machineIdentity } = await server.services.superAdmin.initializeInstance({ + ...req.body + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.AdminInit, + distinctId: user.user.username ?? "", + properties: { + username: user.user.username, + email: user.user.email ?? "", + lastName: user.user.lastName || "", + firstName: user.user.firstName || "" + } + }); + + return { + message: "Successfully initialized instance", + user: user.user, + organization, + machineIdentity + }; + } + }); }; diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index ceaf3ecfc..3f9e5c523 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -12,6 +12,7 @@ export type TUpdateIdentityDTO = { role?: string; name?: string; metadata?: { key: string; value: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TDeleteIdentityDTO = { diff --git a/backend/src/services/super-admin/super-admin-fns.ts b/backend/src/services/super-admin/super-admin-fns.ts new file mode 100644 index 000000000..b7c00dd44 --- /dev/null +++ b/backend/src/services/super-admin/super-admin-fns.ts @@ -0,0 +1,15 @@ +import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; + +import { ActorType } from "../auth/auth-type"; + +export const isSuperAdmin = (auth: TAuthMode) => { + if (auth.actor === ActorType.USER && auth.user.superAdmin) { + return true; + } + + if (auth.actor === ActorType.IDENTITY && auth.isInstanceAdmin) { + return true; + } + + return false; +}; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 1f343b75f..2164ca146 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,16 +1,21 @@ import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; -import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { getUserPrivateKey } from "@app/lib/crypto/srp"; +import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityTokenAuthDALFactory } from "../identity-token-auth/identity-token-auth-dal"; import { KMS_ROOT_CONFIG_UUID } from "../kms/kms-fns"; import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -20,10 +25,19 @@ import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; +import { + LoginMethod, + TAdminGetIdentitiesDTO, + TAdminGetUsersDTO, + TAdminInitializeInstanceDTO, + TAdminSignUpDTO +} from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { - identityDAL: Pick; + identityDAL: TIdentityDALFactory; + identityTokenAuthDAL: TIdentityTokenAuthDALFactory; + identityAccessTokenDAL: TIdentityAccessTokenDALFactory; + identityOrgMembershipDAL: TIdentityOrgDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -60,7 +74,10 @@ export const superAdminServiceFactory = ({ keyStore, kmsRootConfigDAL, kmsService, - licenseService + licenseService, + identityAccessTokenDAL, + identityTokenAuthDAL, + identityOrgMembershipDAL }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -274,6 +291,137 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; + const initializeInstance = async ({ email, password, organizationName }: TAdminInitializeInstanceDTO) => { + const appCfg = getConfig(); + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (serverCfg?.initialized) { + throw new BadRequestError({ message: "Instance has already been set up" }); + } + + const existingUser = await userDAL.findOne({ email }); + if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" }); + + const userInfo = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.create( + { + firstName: "Admin", + lastName: "User", + username: email, + email, + superAdmin: true, + isGhost: false, + isAccepted: true, + authMethods: [AuthMethod.EMAIL], + isEmailVerified: true + }, + tx + ); + const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password); + const encKeys = await generateUserSrpKeys(email, password); + + const userEnc = await userDAL.createUserEncryption( + { + userId: newUser.id, + encryptionVersion: 2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + + return { user: newUser, enc: userEnc }; + }); + + const initialOrganizationName = organizationName ?? "Admin Org"; + + const organization = await orgService.createOrganization({ + userId: userInfo.user.id, + userEmail: userInfo.user.email, + orgName: initialOrganizationName + }); + + const { identity, credentials } = await identityDAL.transaction(async (tx) => { + const newIdentity = await identityDAL.create({ name: "Admin Identity" }, tx); + await identityOrgMembershipDAL.create( + { + identityId: newIdentity.id, + orgId: organization.id, + role: OrgMembershipRole.Admin + }, + tx + ); + + const tokenAuth = await identityTokenAuthDAL.create( + { + identityId: newIdentity.id, + accessTokenMaxTTL: 0, + accessTokenTTL: 0, + accessTokenNumUsesLimit: 0, + accessTokenTrustedIps: JSON.stringify([ + { + type: "ipv4", + prefix: 0, + ipAddress: "0.0.0.0" + }, + { + type: "ipv6", + prefix: 0, + ipAddress: "::" + } + ]) + }, + tx + ); + + const newToken = await identityAccessTokenDAL.create( + { + identityId: newIdentity.id, + isAccessTokenRevoked: false, + accessTokenTTL: tokenAuth.accessTokenTTL, + accessTokenMaxTTL: tokenAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: tokenAuth.accessTokenNumUsesLimit, + name: "Instance Admin Token", + authMethod: IdentityAuthMethod.TOKEN_AUTH + }, + tx + ); + + const generatedAccessToken = jwt.sign( + { + identityId: newIdentity.id, + identityAccessTokenId: newToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET + ); + + return { identity: newIdentity, auth: tokenAuth, credentials: { token: generatedAccessToken } }; + }); + + await updateServerCfg({ initialized: true, adminIdentityIds: [identity.id] }, userInfo.user.id); + + return { + user: userInfo, + organization, + machineIdentity: { + ...identity, + credentials + } + }; + }; + const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => { return userDAL.getUsersByFilter({ limit, @@ -289,13 +437,19 @@ export const superAdminServiceFactory = ({ return user; }; - const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { - return identityDAL.getIdentitiesByFilter({ + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { + const identities = await identityDAL.getIdentitiesByFilter({ limit, offset, searchTerm, sortBy: "name" }); + const serverCfg = await getServerCfg(); + + return identities.map((identity) => ({ + ...identity, + isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id)) + })); }; const grantServerAdminAccessToUser = async (userId: string) => { @@ -393,6 +547,7 @@ export const superAdminServiceFactory = ({ initServerCfg, updateServerCfg, adminSignUp, + initializeInstance, getUsers, deleteUser, getIdentities, diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 54a42c2ca..ad989fcf4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -16,6 +16,12 @@ export type TAdminSignUpDTO = { userAgent: string; }; +export type TAdminInitializeInstanceDTO = { + email: string; + password: string; + organizationName: string; +}; + export type TAdminGetUsersDTO = { offset: number; limit: number; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 496990abe..b24841dbd 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,6 +1,7 @@ import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { Identity } from "@app/hooks/api/identities/types"; import { User } from "../types"; import { @@ -10,7 +11,6 @@ import { TGetServerRootKmsEncryptionDetails, TServerConfig } from "./types"; -import { Identity } from "@app/hooks/api/identities/types"; export const adminStandaloneKeys = { getUsers: "get-users", diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 8d344e7f7..da685392c 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -15,6 +15,7 @@ export type Identity = { authMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; + isInstanceAdmin?: boolean; }; export type IdentityAccessToken = { diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 776c5e20f..f5aaf8c9f 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -28,13 +28,13 @@ import { useGetServerRootKmsEncryptionDetails, useUpdateServerConfig } from "@app/hooks/api"; +import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel"; import { AuthPanel } from "./components/AuthPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; import { RateLimitPanel } from "./components/RateLimitPanel"; import { UserPanel } from "./components/UserPanel"; -import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel"; enum TabSections { Settings = "settings", @@ -59,8 +59,8 @@ const formSchema = z.object({ trustLdapEmails: z.boolean(), trustOidcEmails: z.boolean(), defaultAuthOrgId: z.string(), - authConsentContent: z.string().optional(), - pageFrameContent: z.string().optional() + authConsentContent: z.string().optional().default(""), + pageFrameContent: z.string().optional().default("") }); type TDashboardForm = z.infer; @@ -86,8 +86,8 @@ export const OverviewPage = () => { trustLdapEmails: config.trustLdapEmails, trustOidcEmails: config.trustOidcEmails, defaultAuthOrgId: config.defaultAuthOrgId ?? "", - authConsentContent: config.authConsentContent, - pageFrameContent: config.pageFrameContent + authConsentContent: config.authConsentContent ?? "", + pageFrameContent: config.pageFrameContent ?? "" } }); diff --git a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx index ee2166a4e..81ae19d4f 100644 --- a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx @@ -3,6 +3,7 @@ import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { + Badge, Button, EmptyState, Input, @@ -54,9 +55,16 @@ const IdentityPanelTable = () => { {isPending && } {!isPending && data?.pages?.map((identities) => - identities.map(({ name, id }) => ( + identities.map(({ name, id, isInstanceAdmin }) => ( - {name} + + {name} + {isInstanceAdmin && ( + + Server Admin + + )} + )) )}