diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 99c7c4184..5d10830ad 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -106,6 +106,7 @@ declare module "@fastify/request-context" { claims: Record; }; }; + identityPermissionMetadata?: Record; // filled by permission service } } 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/migrations/20250319134021_recursive-folder-index.ts b/backend/src/db/migrations/20250319134021_recursive-folder-index.ts new file mode 100644 index 000000000..e26b6d876 --- /dev/null +++ b/backend/src/db/migrations/20250319134021_recursive-folder-index.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesParentColumExist = await knex.schema.hasColumn(TableName.SecretFolder, "parentId"); + const doesNameColumnExist = await knex.schema.hasColumn(TableName.SecretFolder, "name"); + if (doesParentColumExist && doesNameColumnExist) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.index(["parentId", "name"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesParentColumExist = await knex.schema.hasColumn(TableName.SecretFolder, "parentId"); + const doesNameColumnExist = await knex.schema.hasColumn(TableName.SecretFolder, "name"); + if (doesParentColumExist && doesNameColumnExist) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.dropIndex(["parentId", "name"]); + }); + } +} 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/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 3b860864f..295923ef5 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -1,8 +1,10 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { ActionProjectType } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; @@ -81,8 +83,12 @@ export const auditLogServiceFactory = ({ if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must specify either project id or org id" }); } - - return auditLogQueue.pushToLog(data); + const el = { ...data }; + if (el.actor.type === ActorType.USER || el.actor.type === ActorType.IDENTITY) { + const permissionMetadata = requestContext.get("identityPermissionMetadata"); + el.actor.metadata.permission = permissionMetadata; + } + return auditLogQueue.pushToLog(el); }; return { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 9244e569e..eef11b8a6 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -290,6 +290,7 @@ interface UserActorMetadata { userId: string; email?: string | null; username: string; + permission?: Record; } interface ServiceActorMetadata { @@ -300,6 +301,7 @@ interface ServiceActorMetadata { interface IdentityActorMetadata { identityId: string; name: string; + permission?: Record; } interface ScimClientActorMetadata {} diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 910cff70f..7835ccfae 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -50,7 +50,7 @@ export type TLicenseServiceFactory = ReturnType; const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login"; const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/license-login"; -const LICENSE_SERVER_CLOUD_PLAN_TTL = 30; // 30 second +const LICENSE_SERVER_CLOUD_PLAN_TTL = 5 * 60; // 5 mins const FEATURE_CACHE_KEY = (orgId: string) => `infisical-cloud-plan-${orgId}`; export const licenseServiceFactory = ({ @@ -142,7 +142,10 @@ export const licenseServiceFactory = ({ try { if (instanceType === InstanceType.Cloud) { const cachedPlan = await keyStore.getItem(FEATURE_CACHE_KEY(orgId)); - if (cachedPlan) return JSON.parse(cachedPlan) as TFeatureSet; + if (cachedPlan) { + logger.info(`getPlan: plan fetched from cache [orgId=${orgId}] [projectId=${projectId}]`); + return JSON.parse(cachedPlan) as TFeatureSet; + } const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); @@ -170,6 +173,8 @@ export const licenseServiceFactory = ({ JSON.stringify(onPremFeatures) ); return onPremFeatures; + } finally { + logger.info(`getPlan: Process done for [orgId=${orgId}] [projectId=${projectId}]`); } return onPremFeatures; }; diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index cccadb86e..5a6cf3077 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -131,12 +131,12 @@ function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrg } } -const escapeHandlebarsMissingMetadata = (obj: Record) => { +const escapeHandlebarsMissingDict = (obj: Record, key: string) => { const handler = { get(target: Record, prop: string) { - if (!(prop in target)) { + if (!Object.hasOwn(target, prop)) { // eslint-disable-next-line no-param-reassign - target[prop] = `{{identity.metadata.${prop}}}`; // Add missing key as an "own" property + target[prop] = `{{${key}.${prop}}}`; // Add missing key as an "own" property } return target[prop]; } @@ -145,4 +145,4 @@ const escapeHandlebarsMissingMetadata = (obj: Record) => { return new Proxy(obj, handler); }; -export { escapeHandlebarsMissingMetadata, isAuthMethodSaml, validateOrgSSO }; +export { escapeHandlebarsMissingDict, isAuthMethodSaml, validateOrgSSO }; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 6b36234ca..01e2edbe7 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -23,7 +23,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; -import { escapeHandlebarsMissingMetadata, validateOrgSSO } from "./permission-fns"; +import { escapeHandlebarsMissingDict, validateOrgSSO } from "./permission-fns"; import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO, @@ -244,13 +244,13 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const metadataKeyValuePair = escapeHandlebarsMissingMetadata( - objectify( - userProjectPermission.metadata, - (i) => i.key, - (i) => i.value - ) + const unescapedMetadata = objectify( + userProjectPermission.metadata, + (i) => i.key, + (i) => i.value ); + const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); + requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata }); const interpolateRules = templatedRules( { identity: { @@ -318,23 +318,26 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const identityAuthInfo = requestContext.get("identityAuthInfo"); + const unescapedIdentityAuthInfo = requestContext.get("identityAuthInfo"); const unescapedMetadata = objectify( identityProjectPermission.metadata, (i) => i.key, (i) => i.value - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as Record; - if (identityAuthInfo?.identityId === identityId && identityAuthInfo) { - unescapedMetadata.auth = identityAuthInfo; - } - const metadataKeyValuePair = escapeHandlebarsMissingMetadata(unescapedMetadata); + ); + const identityAuthInfo = + unescapedIdentityAuthInfo?.identityId === identityId && unescapedIdentityAuthInfo + ? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth") + : {}; + const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); + + requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata, auth: unescapedIdentityAuthInfo }); const interpolateRules = templatedRules( { identity: { id: identityProjectPermission.identityId, username: identityProjectPermission.username, - metadata: metadataKeyValuePair + metadata: metadataKeyValuePair, + auth: identityAuthInfo } }, { data: false } @@ -428,12 +431,13 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const metadataKeyValuePair = escapeHandlebarsMissingMetadata( + const metadataKeyValuePair = escapeHandlebarsMissingDict( objectify( userProjectPermission.metadata, (i) => i.key, (i) => i.value - ) + ), + "identity.metadata" ); const interpolateRules = templatedRules( { @@ -473,14 +477,14 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const metadataKeyValuePair = escapeHandlebarsMissingMetadata( + const metadataKeyValuePair = escapeHandlebarsMissingDict( objectify( identityProjectPermission.metadata, (i) => i.key, (i) => i.value - ) + ), + "identity.metadata" ); - const interpolateRules = templatedRules( { identity: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 0d13939a2..4ca9c1b12 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -56,6 +56,7 @@ const envSchema = z // TODO(akhilmhdh): will be changed to one ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), + QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), HTTPS_ENABLED: zodStrBool, // smtp options SMTP_HOST: zpStr(z.string().optional()), diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 5d6b8b60b..94006b5a5 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -272,10 +272,13 @@ export const queueServiceFactory = ( connection }); - workerContainer[name] = new Worker(name, jobFn, { - ...queueSettings, - connection - }); + const appCfg = getConfig(); + if (appCfg.QUEUE_WORKERS_ENABLED) { + workerContainer[name] = new Worker(name, jobFn, { + ...queueSettings, + connection + }); + } }; const startPg = async ( @@ -307,6 +310,11 @@ export const queueServiceFactory = ( event: U, listener: WorkerListener[U] ) => { + const appCfg = getConfig(); + if (!appCfg.QUEUE_WORKERS_ENABLED) { + return; + } + const worker = workerContainer[name]; worker.on(event, listener); }; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 5779b39f7..ad5291a13 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -9,6 +9,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 = | { @@ -44,6 +45,7 @@ export type TAuthMode = identityName: string; orgId: string; authMethod: null; + isInstanceAdmin?: boolean; } | { authMode: AuthMode.SCIM_TOKEN; @@ -130,13 +132,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) }; if (token?.identityAuth?.oidc) { requestContext.set("identityAuthInfo", { 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/plugins/secret-scanner.ts b/backend/src/server/plugins/secret-scanner.ts index 96ad5c72c..65dbbb87f 100644 --- a/backend/src/server/plugins/secret-scanner.ts +++ b/backend/src/server/plugins/secret-scanner.ts @@ -65,7 +65,7 @@ export const registerSecretScannerGhApp = async (server: FastifyZodProvider) => payload: JSON.stringify(req.body), signature: signatureSHA256 }); - void res.send("ok"); + return res.send("ok"); } }); } diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 6bcc59d00..9f91d9774 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -34,7 +34,7 @@ export const registerServeUI = async ( TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; - void res.send(js); + return res.send(js); } }); @@ -57,7 +57,7 @@ export const registerServeUI = async ( reply.callNotFound(); return; } - void reply.sendFile("index.html"); + return reply.sendFile("index.html"); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b9f47cb7e..9aa60a631 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1,5 +1,6 @@ import { CronJob } from "cron"; import { Knex } from "knex"; +import { monitorEventLoopDelay } from "perf_hooks"; import { z } from "zod"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; @@ -96,6 +97,7 @@ import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal" import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; @@ -246,6 +248,9 @@ import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +const histogram = monitorEventLoopDelay({ resolution: 20 }); +histogram.enable(); + export const registerRoutes = async ( server: FastifyZodProvider, { @@ -637,6 +642,9 @@ export const registerRoutes = async ( userDAL, identityDAL, userAliasDAL, + identityTokenAuthDAL, + identityAccessTokenDAL, + identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, @@ -1627,6 +1635,18 @@ export const registerRoutes = async ( const cfg = getConfig(); const serverCfg = await getServerCfg(); + const meanLagMs = histogram.mean / 1e6; + const maxLagMs = histogram.max / 1e6; + const p99LagMs = histogram.percentile(99) / 1e6; + + logger.info( + `Event loop stats - Mean: ${meanLagMs.toFixed(2)}ms, Max: ${maxLagMs.toFixed(2)}ms, p99: ${p99LagMs.toFixed( + 2 + )}ms` + ); + + logger.info(`Raw event loop stats: ${JSON.stringify(histogram, null, 2)}`); + // try { // await db.raw("SELECT NOW()"); // } catch (err) { diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a1c433650..6eb1804f1 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,141 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "DELETE", + url: "/identity-management/identities/:identityId/super-admin-access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identity: IdentitiesSchema.pick({ + name: true, + id: true + }) + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const identity = await server.services.superAdmin.deleteIdentitySuperAdminAccess( + req.params.identityId, + req.permission.id + ); + + return { + identity + }; + } + }); + + server.route({ + method: "DELETE", + url: "/user-management/users/:userId/admin-access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + userId: z.string() + }), + response: { + 200: z.object({ + user: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }) + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const user = await server.services.superAdmin.deleteUserSuperAdminAccess(req.params.userId); + + return { + user + }; + } + }); + + server.route({ + method: "POST", + url: "/bootstrap", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + email: z.string().email().trim().min(1), + password: z.string().trim().min(1), + organization: z.string().trim().min(1) + }), + response: { + 200: z.object({ + message: z.string(), + user: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true, + superAdmin: true + }), + organization: OrganizationsSchema.pick({ + id: true, + name: true, + slug: true + }), + identity: IdentitiesSchema.pick({ + id: true, + name: true + }).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.bootstrapInstance({ + ...req.body, + organizationName: req.body.organization + }); + + 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 bootstrapped instance", + user: user.user, + organization, + identity: machineIdentity + }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index 414f8534c..82387a3cc 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -11,6 +11,7 @@ import { validateAccountIds, validatePrincipalArns } from "@app/services/identity-aws-auth/identity-aws-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) => { server.route({ @@ -130,7 +131,8 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index f46fb57ca..1cf59e682 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -8,8 +8,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { validateAzureAuthField } from "@app/services/identity-azure-auth/identity-azure-auth-validators"; - -import {} from "../sanitizedSchemas"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => { server.route({ @@ -127,7 +126,8 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 057458bb2..d269072d9 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -8,6 +8,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { validateGcpAuthField } from "@app/services/identity-gcp-auth/identity-gcp-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) => { server.route({ @@ -121,7 +122,8 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 2950fc72d..bb09898a3 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -12,6 +12,7 @@ import { validateJwtAuthAudiencesField, validateJwtBoundClaimsField } from "@app/services/identity-jwt-auth/identity-jwt-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ encryptedJwksCaCert: true, @@ -169,7 +170,8 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 263fa478e..952cfcdaf 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -7,6 +7,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick({ id: true, @@ -147,7 +148,8 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index f3098c851..4f1f75f5c 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -11,6 +11,7 @@ import { validateOidcAuthAudiencesField, validateOidcBoundClaimsField } from "@app/services/identity-oidc-auth/identity-oidc-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.pick({ id: true, @@ -148,7 +149,8 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 5ec688061..344da3383 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -7,6 +7,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -118,6 +119,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, id: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth), ...req.body }); @@ -166,7 +168,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - id: req.params.identityId + id: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index 3d331403a..d6e7259be 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -7,6 +7,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider) => { server.route({ @@ -74,7 +75,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ @@ -157,7 +159,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ @@ -257,7 +260,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); await server.services.auditLog.createAuditLog({ @@ -312,6 +316,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth), ...req.body }); @@ -370,6 +375,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth), ...req.query }); @@ -421,6 +427,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, tokenId: req.params.tokenId, + isActorSuperAdmin: isSuperAdmin(req.auth), ...req.body }); @@ -470,7 +477,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - tokenId: req.params.tokenId + tokenId: req.params.tokenId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); return { diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index e48e1f442..5a9363f3d 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -7,6 +7,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; export const sanitizedClientSecretSchema = IdentityUaClientSecretsSchema.pick({ id: true, @@ -142,8 +143,10 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - identityId: req.params.identityId + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: identityUniversalAuth.orgId, diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index ddd9e0278..5be3dfe4c 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -16,6 +16,7 @@ import { ActorType, 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 { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; import { extractPrincipalArn } from "./identity-aws-auth-fns"; import { @@ -149,8 +150,11 @@ export const identityAwsAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachAwsAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts index c24186ee0..785b37bbc 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -16,6 +16,7 @@ export type TAttachAwsAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateAwsAuthDTO = { diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 01878dbb3..87e076e93 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -14,6 +14,7 @@ import { ActorType, 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 { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; import { validateAzureIdentity } from "./identity-azure-auth-fns"; import { @@ -122,8 +123,11 @@ export const identityAzureAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachAzureAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts index ec03451db..485753b6f 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -14,6 +14,7 @@ export type TAttachAzureAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateAzureAuthDTO = { diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 7b0dd4390..321f633ff 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -14,6 +14,7 @@ import { ActorType, 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 { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityGcpAuthDALFactory } from "./identity-gcp-auth-dal"; import { validateIamIdentity, validateIdTokenIdentity } from "./identity-gcp-auth-fns"; import { @@ -162,8 +163,11 @@ export const identityGcpAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachGcpAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts index 45e64b24b..063630c73 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -15,6 +15,7 @@ export type TAttachGcpAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateGcpAuthDTO = { diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 9844b337f..988785d7a 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -19,6 +19,7 @@ import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identit import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns"; import { @@ -250,8 +251,11 @@ export const identityJwtAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachJwtAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) { if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts index a6881f0e5..bc19aba83 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -19,6 +19,7 @@ export type TAttachJwtAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateJwtAuthDTO = { diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index c7b2c5c8d..8e4e46bc7 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -18,6 +18,7 @@ import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identit import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { @@ -101,7 +102,8 @@ export const identityKubernetesAuthServiceFactory = ({ "Content-Type": "application/json", Authorization: `Bearer ${tokenReviewerJwt}` }, - + signal: AbortSignal.timeout(10000), + timeout: 10000, // if ca cert, rejectUnauthorized: true httpsAgent: new https.Agent({ ca: caCert, @@ -227,8 +229,11 @@ export const identityKubernetesAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachKubernetesAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index f1cde2be9..c66ec8480 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -17,6 +17,7 @@ export type TAttachKubernetesAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateKubernetesAuthDTO = { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index cb641af4d..78d5cd33a 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -20,6 +20,7 @@ import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identit import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal"; import { doesAudValueMatchOidcPolicy, doesFieldValueMatchOidcPolicy } from "./identity-oidc-auth-fns"; import { @@ -222,8 +223,10 @@ export const identityOidcAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachOidcAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) { if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts index 9727285e7..fc5da3e27 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts @@ -13,6 +13,7 @@ export type TAttachOidcAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateOidcAuthDTO = { diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index d9e2d66fa..1d327fff3 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -14,6 +14,7 @@ import { ActorType, 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 { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityTokenAuthDALFactory } from "./identity-token-auth-dal"; import { TAttachTokenAuthDTO, @@ -59,8 +60,11 @@ export const identityTokenAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachTokenAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -126,8 +130,11 @@ export const identityTokenAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TUpdateTokenAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -218,8 +225,11 @@ export const identityTokenAuthServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TRevokeTokenAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -271,8 +281,11 @@ export const identityTokenAuthServiceFactory = ({ actor, actorAuthMethod, actorOrgId, - name + name, + isActorSuperAdmin }: TCreateTokenAuthTokenDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -350,8 +363,11 @@ export const identityTokenAuthServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TGetTokenAuthTokensDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -386,7 +402,8 @@ export const identityTokenAuthServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TUpdateTokenAuthTokenDTO) => { const foundToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: tokenId, @@ -398,6 +415,8 @@ export const identityTokenAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: `Failed to find identity with ID ${foundToken.identityId}` }); } + + await validateIdentityUpdateForSuperAdminPrivileges(foundToken.identityId, isActorSuperAdmin); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ message: "The identity does not have Token Auth" @@ -446,18 +465,22 @@ export const identityTokenAuthServiceFactory = ({ actorId, actor, actorAuthMethod, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TRevokeTokenAuthTokenDTO) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: tokenId, [`${TableName.IdentityAccessToken}.isAccessTokenRevoked` as "isAccessTokenRevoked"]: false, [`${TableName.IdentityAccessToken}.authMethod` as "authMethod"]: IdentityAuthMethod.TOKEN_AUTH }); + if (!identityAccessToken) throw new NotFoundError({ message: `Token with ID ${tokenId} not found or already revoked` }); + await validateIdentityUpdateForSuperAdminPrivileges(identityAccessToken.identityId, isActorSuperAdmin); + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: identityAccessToken.identityId }); diff --git a/backend/src/services/identity-token-auth/identity-token-auth-types.ts b/backend/src/services/identity-token-auth/identity-token-auth-types.ts index 12c689728..16cd60db7 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-types.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-types.ts @@ -6,6 +6,7 @@ export type TAttachTokenAuthDTO = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateTokenAuthDTO = { @@ -14,6 +15,7 @@ export type TUpdateTokenAuthDTO = { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TGetTokenAuthDTO = { @@ -22,24 +24,29 @@ export type TGetTokenAuthDTO = { export type TRevokeTokenAuthDTO = { identityId: string; + isActorSuperAdmin?: boolean; } & Omit; export type TCreateTokenAuthTokenDTO = { identityId: string; name?: string; + isActorSuperAdmin?: boolean; } & Omit; export type TGetTokenAuthTokensDTO = { identityId: string; offset: number; limit: number; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateTokenAuthTokenDTO = { tokenId: string; name?: string; + isActorSuperAdmin?: boolean; } & Omit; export type TRevokeTokenAuthTokenDTO = { tokenId: string; + isActorSuperAdmin?: boolean; } & Omit; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 078b50c08..fdb7acd52 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -17,6 +17,7 @@ import { ActorType, 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 { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-dal"; import { TIdentityUaDALFactory } from "./identity-ua-dal"; import { @@ -68,9 +69,15 @@ export const identityUaServiceFactory = ({ isClientSecretRevoked: false }); - const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => - bcrypt.compareSync(clientSecret, clientSecretHash) - ); + let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; + for await (const info of clientSecrtInfo) { + const isMatch = await bcrypt.compare(clientSecret, info.clientSecretHash); + if (isMatch) { + validClientSecretInfo = info; + break; + } + } + if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; @@ -103,7 +110,7 @@ export const identityUaServiceFactory = ({ } const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx); + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); const newToken = await identityAccessTokenDAL.create( { identityId: identityUa.identityId, @@ -150,8 +157,11 @@ export const identityUaServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + isActorSuperAdmin }: TAttachUaDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index 2045c2143..07b6a4810 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -7,6 +7,7 @@ export type TAttachUaDTO = { accessTokenNumUsesLimit: number; clientSecretTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TUpdateUaDTO = { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 8ada2a5d1..bd6c8d30c 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -9,6 +9,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { ActorType } from "../auth/auth-type"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityDALFactory } from "./identity-dal"; import { TIdentityMetadataDALFactory } from "./identity-metadata-dal"; import { TIdentityOrgDALFactory } from "./identity-org-dal"; @@ -112,8 +113,11 @@ export const identityServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - metadata + metadata, + isActorSuperAdmin }: TUpdateIdentityDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(id, isActorSuperAdmin); + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); @@ -209,7 +213,16 @@ export const identityServiceFactory = ({ return identity; }; - const deleteIdentity = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteIdentityDTO) => { + const deleteIdentity = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + isActorSuperAdmin + }: TDeleteIdentityDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(id, isActorSuperAdmin); + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index ceaf3ecfc..0eca6b7ee 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -12,10 +12,12 @@ export type TUpdateIdentityDTO = { role?: string; name?: string; metadata?: { key: string; value: string }[]; + isActorSuperAdmin?: boolean; } & Omit; export type TDeleteIdentityDTO = { id: string; + isActorSuperAdmin?: boolean; } & Omit; export type TGetIdentityByIdDTO = { diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 8b48e20d4..4a5fd8231 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -923,16 +923,14 @@ const getAppsCodefresh = async ({ accessToken }: { accessToken: string }) => { /** * Return list of projects for Windmill integration */ -const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { - const { data } = await request.get<{ id: string; name: string }[]>( - `${IntegrationUrls.WINDMILL_API_URL}/workspaces/list`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } +const getAppsWindmill = async ({ accessToken, url }: { accessToken: string; url?: string | null }) => { + const apiUrl = url ? `${url}/api` : IntegrationUrls.WINDMILL_API_URL; + const { data } = await request.get<{ id: string; name: string }[]>(`${apiUrl}/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); // check for write access of secrets in windmill workspaces const writeAccessCheck = data.map(async (app) => { @@ -941,7 +939,7 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { const folderPath = "f/folder/variable"; const { data: writeUser } = await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + `${apiUrl}/w/${app.id}/variables/create`, { path: userPath, value: "variable", @@ -957,7 +955,7 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { ); const { data: writeFolder } = await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + `${apiUrl}/w/${app.id}/variables/create`, { path: folderPath, value: "variable", @@ -974,14 +972,14 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { // is write access is allowed then delete the created secrets from workspace if (writeUser && writeFolder) { - await request.delete(`${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${userPath}`, { + await request.delete(`${apiUrl}/w/${app.id}/variables/delete/${userPath}`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" } }); - await request.delete(`${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${folderPath}`, { + await request.delete(`${apiUrl}/w/${app.id}/variables/delete/${folderPath}`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -1316,7 +1314,8 @@ export const getApps = async ({ case Integrations.WINDMILL: return getAppsWindmill({ - accessToken + accessToken, + url }); case Integrations.DIGITAL_OCEAN_APP_PLATFORM: diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 93318762b..56e506759 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -4127,10 +4127,10 @@ const syncSecretsWindmill = async ({ is_secret: boolean; description?: string; } - + const apiUrl = integration.url ? `${integration.url}/api` : IntegrationUrls.WINDMILL_API_URL; // get secrets stored in windmill workspace const res = ( - await request.get(`${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/list`, { + await request.get(`${apiUrl}/w/${integration.appId}/variables/list`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -4146,7 +4146,6 @@ const syncSecretsWindmill = async ({ // eslint-disable-next-line const pattern = new RegExp("^(u/|f/)[a-zA-Z0-9_-]+/([a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]*[^/]$"); - for await (const key of Object.keys(secrets)) { if ((key.startsWith("u/") || key.startsWith("f/")) && pattern.test(key)) { if (!(key in res)) { @@ -4154,7 +4153,7 @@ const syncSecretsWindmill = async ({ // -> create secret await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/create`, + `${apiUrl}/w/${integration.appId}/variables/create`, { path: key, value: secrets[key].value, @@ -4171,7 +4170,7 @@ const syncSecretsWindmill = async ({ } else { // -> update secret await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/update/${res[key].path}`, + `${apiUrl}/w/${integration.appId}/variables/update/${res[key].path}`, { path: key, value: secrets[key].value, @@ -4192,16 +4191,13 @@ const syncSecretsWindmill = async ({ for await (const key of Object.keys(res)) { if (!(key in secrets)) { // -> delete secret - await request.delete( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/delete/${res[key].path}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Accept-Encoding": "application/json" - } + await request.delete(`${apiUrl}/w/${integration.appId}/variables/delete/${res[key].path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json" } - ); + }); } } }; diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index 434f5f713..c03d4ffc0 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate } from "@app/db/schemas"; +import { TableName, TSecretFolders, TSecretFoldersUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -41,12 +41,12 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str void baseQb .select({ depth: 1, - // latestFolderVerId: db.raw("NULL::uuid"), path: db.raw("'/'") }) .from(TableName.SecretFolder) .where({ - parentId: null + parentId: null, + name: "root" }) .whereIn( "envId", @@ -69,9 +69,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str .where((wb) => formatedQuery.map(({ secretPath }) => wb.orWhereRaw( - `depth = array_position(ARRAY[${secretPath.map(() => "?").join(",")}]::varchar[], ${ - TableName.SecretFolder - }.name,depth)`, + `secret_folders.name = (ARRAY[${secretPath.map(() => "?").join(",")}]::varchar[])[depth]`, [...secretPath] ) ) @@ -107,7 +105,6 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str void baseQb .select({ depth: 1, - // latestFolderVerId: db.raw("NULL::uuid"), path: db.raw("'/'") }) .from(TableName.SecretFolder) @@ -117,6 +114,11 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str parentId: null }) .whereIn(`${TableName.Environment}.slug`, environments) + .select( + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("projectId").withSchema(TableName.Environment) + ) .select(selectAllTableCols(TableName.SecretFolder)) .union( (qb) => @@ -128,21 +130,20 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str depth: db.raw("parent.depth + 1"), path: db.raw( "CONCAT((CASE WHEN parent.path = '/' THEN '' ELSE parent.path END),'/', secret_folders.name)" - ) + ), + envSlug: db.ref("envSlug").withSchema("parent"), + envName: db.ref("envName").withSchema("parent"), + projectId: db.ref("projectId").withSchema("parent") }) .select(selectAllTableCols(TableName.SecretFolder)) - .whereRaw( - `depth = array_position(ARRAY[${pathSegments - .map(() => "?") - .join(",")}]::varchar[], secret_folders.name,depth)`, - [...pathSegments] - ) + .whereRaw(`secret_folders.name = (ARRAY[${pathSegments.map(() => "?").join(",")}]::varchar[])[depth]`, [ + ...pathSegments + ]) .from(TableName.SecretFolder) .join("parent", "parent.id", `${TableName.SecretFolder}.parentId`) ); }) .from("parent") - .leftJoin(TableName.Environment, `${TableName.Environment}.id`, "parent.envId") .select< (TSecretFolders & { depth: number; @@ -152,13 +153,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str envName: string; projectId: string; })[] - >( - selectAllTableCols("parent" as TableName.SecretFolder), - db.ref("id").withSchema(TableName.Environment).as("envId"), - db.ref("slug").withSchema(TableName.Environment).as("envSlug"), - db.ref("name").withSchema(TableName.Environment).as("envName"), - db.ref("projectId").withSchema(TableName.Environment) - ); + >(selectAllTableCols("parent" as TableName.SecretFolder)); }; const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: string[]) => @@ -220,19 +215,12 @@ export const secretFolderDALFactory = (db: TDbClient) => { throw new BadRequestError({ message: "Invalid secret path. Only alphanumeric characters, dashes, and underscores are allowed." }); - + const formatedPath = removeTrailingSlash(path); try { - const folder = await sqlFindFolderByPathQuery( - tx || db.replicaNode(), - projectId, - [environment], - removeTrailingSlash(path) - ) - .orderBy("depth", "desc") + const query = sqlFindFolderByPathQuery(tx || db.replicaNode(), projectId, [environment], formatedPath) + .where("path", formatedPath) .first(); - if (folder && folder.path !== removeTrailingSlash(path)) { - return; - } + const folder = await query; if (!folder) return; const { envId: id, envName: name, envSlug: slug, ...el } = folder; return { ...el, envId: id, environment: { id, name, slug } }; @@ -250,22 +238,13 @@ export const secretFolderDALFactory = (db: TDbClient) => { }); try { - const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1; - + const formatedPath = removeTrailingSlash(path); const folders = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, environments, - removeTrailingSlash(path) - ) - .orderBy("depth", "desc") - .where("depth", pathDepth); - - const firstFolder = folders[0]; - - if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) { - return []; - } + formatedPath + ).where("path", removeTrailingSlash(path)); return folders.map((folder) => { const { envId: id, envName: name, envSlug: slug, ...el } = folder; @@ -323,7 +302,6 @@ export const secretFolderDALFactory = (db: TDbClient) => { const findSecretPathByFolderIds = async (projectId: string, folderIds: string[], tx?: Knex) => { try { const folders = await sqlFindSecretPathByFolderId(tx || db.replicaNode(), projectId, folderIds); - // travelling all the way from leaf node to root contains real path const rootFolders = groupBy( folders.filter(({ parentId }) => parentId === null), 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..12ac0e7d7 --- /dev/null +++ b/backend/src/services/super-admin/super-admin-fns.ts @@ -0,0 +1,30 @@ +import { ForbiddenRequestError } from "@app/lib/errors"; +import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; + +import { ActorType } from "../auth/auth-type"; +import { getServerCfg } from "./super-admin-service"; + +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; +}; + +export const validateIdentityUpdateForSuperAdminPrivileges = async ( + identityId: string, + isActorSuperAdmin?: boolean +) => { + const serverCfg = await getServerCfg(); + if (serverCfg.adminIdentityIds?.includes(identityId) && !isActorSuperAdmin) { + throw new ForbiddenRequestError({ + message: + "You are attempting to modify an instance admin identity. This requires elevated instance admin privileges" + }); + } +}; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 1f343b75f..317348cac 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, + TAdminBootstrapInstanceDTO, + TAdminGetIdentitiesDTO, + TAdminGetUsersDTO, + 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 bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => { + 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: "Instance 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,46 @@ export const superAdminServiceFactory = ({ return user; }; - const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { - return identityDAL.getIdentitiesByFilter({ + const deleteIdentitySuperAdminAccess = async (identityId: string, actorId: string) => { + const identity = await identityDAL.findById(identityId); + if (!identity) { + throw new NotFoundError({ name: "Identity", message: "Identity not found" }); + } + + const currentAdminIdentityIds = (await getServerCfg()).adminIdentityIds ?? []; + if (!currentAdminIdentityIds?.includes(identityId)) { + throw new BadRequestError({ name: "Identity", message: "Identity does not have super admin access" }); + } + + await updateServerCfg({ adminIdentityIds: currentAdminIdentityIds.filter((id) => id !== identityId) }, actorId); + + return identity; + }; + + const deleteUserSuperAdminAccess = async (userId: string) => { + const user = await userDAL.findById(userId); + if (!user) { + throw new NotFoundError({ name: "User", message: "User not found" }); + } + + const updatedUser = userDAL.updateById(userId, { superAdmin: false }); + + return updatedUser; + }; + + 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,12 +574,15 @@ export const superAdminServiceFactory = ({ initServerCfg, updateServerCfg, adminSignUp, + bootstrapInstance, getUsers, deleteUser, getIdentities, getAdminSlackConfig, updateRootEncryptionStrategy, getConfiguredEncryptionStrategies, - grantServerAdminAccessToUser + grantServerAdminAccessToUser, + deleteIdentitySuperAdminAccess, + deleteUserSuperAdminAccess }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 54a42c2ca..64ec92632 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 TAdminBootstrapInstanceDTO = { + email: string; + password: string; + organizationName: string; +}; + export type TAdminGetUsersDTO = { offset: number; limit: number; diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 454257405..ec92f2ad2 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -600,3 +600,23 @@ func CallGatewayHeartBeatV1(httpClient *resty.Client) error { return nil } + +func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRequest) (map[string]interface{}, error) { + var resBody map[string]interface{} + response, err := httpClient. + R(). + SetResult(&resBody). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/admin/bootstrap", request.Domain)) + + if err != nil { + return nil, fmt.Errorf("CallBootstrapInstance: Unable to complete api request [err=%w]", err) + } + + if response.IsError() { + return nil, fmt.Errorf("CallBootstrapInstance: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + } + + return resBody, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 72dbbc97b..a7a797a0b 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -648,3 +648,10 @@ type ExchangeRelayCertResponseV1 struct { Certificate string `json:"certificate"` CertificateChain string `json:"certificateChain"` } + +type BootstrapInstanceRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Organization string `json:"organization"` + Domain string `json:"domain"` +} diff --git a/cli/packages/cmd/bootstrap.go b/cli/packages/cmd/bootstrap.go new file mode 100644 index 000000000..008debbac --- /dev/null +++ b/cli/packages/cmd/bootstrap.go @@ -0,0 +1,104 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var bootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Used to bootstrap your Infisical instance", + DisableFlagsInUseLine: true, + Example: "infisical bootstrap", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + email, _ := cmd.Flags().GetString("email") + if email == "" { + if envEmail, ok := os.LookupEnv("INFISICAL_ADMIN_EMAIL"); ok { + email = envEmail + } + } + + if email == "" { + log.Error().Msg("email is required") + return + } + + password, _ := cmd.Flags().GetString("password") + if password == "" { + if envPassword, ok := os.LookupEnv("INFISICAL_ADMIN_PASSWORD"); ok { + password = envPassword + } + } + + if password == "" { + log.Error().Msg("password is required") + return + } + + organization, _ := cmd.Flags().GetString("organization") + if organization == "" { + if envOrganization, ok := os.LookupEnv("INFISICAL_ADMIN_ORGANIZATION"); ok { + organization = envOrganization + } + } + + if organization == "" { + log.Error().Msg("organization is required") + return + } + + domain, _ := cmd.Flags().GetString("domain") + if domain == "" { + if envDomain, ok := os.LookupEnv("INFISICAL_API_URL"); ok { + domain = envDomain + } + } + + if domain == "" { + log.Error().Msg("domain is required") + return + } + + httpClient := resty.New(). + SetHeader("Accept", "application/json") + + bootstrapResponse, err := api.CallBootstrapInstance(httpClient, api.BootstrapInstanceRequest{ + Domain: util.AppendAPIEndpoint(domain), + Email: email, + Password: password, + Organization: organization, + }) + + if err != nil { + log.Error().Msgf("Failed to bootstrap instance: %v", err) + return + } + + responseJSON, err := json.MarshalIndent(bootstrapResponse, "", " ") + if err != nil { + log.Fatal().Msgf("Failed to convert response to JSON: %v", err) + return + } + fmt.Println(string(responseJSON)) + }, +} + +func init() { + bootstrapCmd.Flags().String("domain", "", "The domain of your self-hosted Infisical instance") + bootstrapCmd.Flags().String("email", "", "The desired email address of the instance admin") + bootstrapCmd.Flags().String("password", "", "The desired password of the instance admin") + bootstrapCmd.Flags().String("organization", "", "The name of the organization to create for the instance") + + rootCmd.AddCommand(bootstrapCmd) +} diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index c846db10d..8e9f5d63b 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -143,7 +143,15 @@ var secretsSetCmd = &cobra.Command{ Short: "Used set secrets", Use: "set [secrets]", DisableFlagsInUseLine: true, - Args: cobra.MinimumNArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("file") { + if len(args) > 0 { + return fmt.Errorf("secrets cannot be provided as command-line arguments when the --file option is used. Please choose either file-based or argument-based secret input") + } + return nil + } + return cobra.MinimumNArgs(1)(cmd, args) + }, Run: func(cmd *cobra.Command, args []string) { token, err := util.GetInfisicalToken(cmd) if err != nil { @@ -177,13 +185,18 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse secret type") } + file, err := cmd.Flags().GetString("file") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var secretOperations []models.SecretSetOperation if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { if projectId == "" { util.PrintErrorMessageAndExit("When using service tokens or machine identities, you must set the --projectId flag") } - secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token) + secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token, file) } else { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -206,7 +219,7 @@ var secretsSetCmd = &cobra.Command{ secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ Type: "", Token: loggedInUserDetails.UserCredentials.JTWToken, - }) + }, file) } if err != nil { @@ -691,6 +704,7 @@ func init() { secretsSetCmd.Flags().String("projectId", "", "manually set the project ID to for setting secrets when using machine identity based auth") secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") + secretsSetCmd.Flags().String("file", "", "Load secrets from the specified file. File format: .env or YAML (comments: # or //). This option is mutually exclusive with command-line secrets arguments.") secretsDeleteCmd.Flags().String("type", "personal", "the type of secret to delete: personal or shared (default: personal)") secretsDeleteCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 5a2a0ec24..02079d656 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -17,6 +17,7 @@ import ( "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" + "gopkg.in/yaml.v3" ) func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool, tagSlugs string, expandSecretReferences bool) ([]models.SingleEnvironmentVariable, error) { @@ -564,7 +565,99 @@ func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey str return crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey), nil } -func SetRawSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string, projectId string, tokenDetails *models.TokenDetails) ([]models.SecretSetOperation, error) { +func parseSecrets(fileName string, content string) (map[string]string, error) { + secrets := make(map[string]string) + + if strings.HasSuffix(fileName, ".yaml") || strings.HasSuffix(fileName, ".yml") { + // Handle YAML secrets + var yamlData map[string]interface{} + if err := yaml.Unmarshal([]byte(content), &yamlData); err != nil { + return nil, fmt.Errorf("failed to parse YAML file: %v", err) + } + + for key, value := range yamlData { + if strValue, ok := value.(string); ok { + secrets[key] = strValue + } else { + return nil, fmt.Errorf("YAML secret '%s' must be a string", key) + } + } + } else { + // Handle .env files + lines := strings.Split(content, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Ignore empty lines and comments + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + // Ensure it's a valid key=value pair + splitKeyValue := strings.SplitN(line, "=", 2) + if len(splitKeyValue) != 2 { + return nil, fmt.Errorf("invalid format, expected key=value in line: %s", line) + } + + key, value := strings.TrimSpace(splitKeyValue[0]), strings.TrimSpace(splitKeyValue[1]) + + // Handle quoted values + if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || + (strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`)) { + value = value[1 : len(value)-1] // Remove surrounding quotes + } + + secrets[key] = value + } + } + + return secrets, nil +} + +func validateSecretKey(key string) error { + if key == "" { + return errors.New("secret keys cannot be empty") + } + if unicode.IsNumber(rune(key[0])) { + return fmt.Errorf("secret key '%s' cannot start with a number", key) + } + if strings.Contains(key, " ") { + return fmt.Errorf("secret key '%s' cannot contain spaces", key) + } + return nil +} + +func SetRawSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string, projectId string, tokenDetails *models.TokenDetails, file string) ([]models.SecretSetOperation, error) { + if file != "" { + content, err := os.ReadFile(file) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + PrintErrorMessageAndExit("File does not exist") + } + return nil, fmt.Errorf("unable to process file [err=%v]", err) + } + + parsedSecrets, err := parseSecrets(file, string(content)) + if err != nil { + PrintErrorMessageAndExit(fmt.Sprintf("error parsing secrets: %v", err)) + } + + // Step 2: Validate secrets + for key, value := range parsedSecrets { + if err := validateSecretKey(key); err != nil { + PrintErrorMessageAndExit(err.Error()) + } + if strings.TrimSpace(value) == "" { + PrintErrorMessageAndExit(fmt.Sprintf("Secret key '%s' has an empty value", key)) + } + secretArgs = append(secretArgs, fmt.Sprintf("%s=%s", key, value)) + } + + if len(secretArgs) == 0 { + PrintErrorMessageAndExit("no valid secrets found in the file") + } + } if tokenDetails == nil { return nil, fmt.Errorf("unable to process set secret operations, token details are missing") diff --git a/docs/cli/commands/bootstrap.mdx b/docs/cli/commands/bootstrap.mdx new file mode 100644 index 000000000..77f8b38f1 --- /dev/null +++ b/docs/cli/commands/bootstrap.mdx @@ -0,0 +1,132 @@ +--- +title: "infisical bootstrap" +description: "Automate the initial setup of a new Infisical instance for headless deployment and infrastructure-as-code workflows" +--- + +```bash +infisical bootstrap --domain= --email= --password= --organization= +``` + +## Description + +The `infisical bootstrap` command is used when deploying Infisical in automated environments where manual UI setup is not feasible. It's ideal for: + +- Containerized deployments in Kubernetes or Docker environments +- Infrastructure-as-code pipelines with Terraform or similar tools +- Continuous deployment workflows +- DevOps automation scenarios + +The command initializes a fresh Infisical instance by creating an admin user, organization, and instance admin machine identity, enabling subsequent programmatic configuration without human intervention. + + + This command creates an instance admin machine identity with the highest level + of privileges. The returned token should be treated with the utmost security, + similar to a root credential. Unauthorized access to this token could + compromise your entire Infisical instance. + + +## Flags + + + The URL of your Infisical instance. This can be set using the `INFISICAL_API_URL` environment variable. + +```bash +# Example +infisical bootstrap --domain=https://your-infisical-instance.com +``` + +This flag is required. + + + + + Email address for the admin user account that will be created. This can be set using the `INFISICAL_ADMIN_EMAIL` environment variable. + +```bash +# Example +infisical bootstrap --email=admin@example.com +``` + +This flag is required. + + + + + Password for the admin user account. This can be set using the `INFISICAL_ADMIN_PASSWORD` environment variable. + +```bash +# Example +infisical bootstrap --password=your-secure-password +``` + +This flag is required. + + + + + Name of the organization that will be created within the instance. This can be set using the `INFISICAL_ADMIN_ORGANIZATION` environment variable. + +```bash +# Example +infisical bootstrap --organization=your-org-name +``` + +This flag is required. + + + +## Response + +The command returns a JSON response with details about the created user, organization, and machine identity: + +```json +{ + "identity": { + "credentials": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiZGIyMjQ3OTItZWQxOC00Mjc3LTlkYWUtNTdlNzUyMzE1ODU0IiwiaWRlbnRpdHlBY2Nlc3NUb2tlbklkIjoiZmVkZmZmMGEtYmU3Yy00NjViLWEwZWEtZjM5OTNjMTg4OGRlIiwiYXV0aFRva2VuVHlwZSI6ImlkZW50aXR5QWNjZXNzVG9rZW4iLCJpYXQiOjE3NDIzMjI0ODl9.mqcZZqIFqER1e9ubrQXp8FbzGYi8nqqZwfMvz09g-8Y" + }, + "id": "db224792-ed18-4277-9dae-57e752315854", + "name": "Instance Admin Identity" + }, + "message": "Successfully bootstrapped instance", + "organization": { + "id": "b56bece0-42f5-4262-b25e-be7bf5f84957", + "name": "dog", + "slug": "dog-v-e5l" + }, + "user": { + "email": "admin@example.com", + "firstName": "Admin", + "id": "a418f355-c8da-453c-bbc8-6c07208eeb3c", + "lastName": "User", + "superAdmin": true, + "username": "admin@example.com" + } +} +``` + +## Usage with Automation + +For automation purposes, you can extract just the machine identity token from the response: + +```bash +infisical bootstrap --domain=https://your-infisical-instance.com --email=admin@example.com --password=your-secure-password --organization=your-org-name | jq ".identity.credentials.token" +``` + +This extracts only the token, which can be captured in a variable or piped to other commands. + +## Example: Capture Token in a Variable + +```bash +TOKEN=$(infisical bootstrap --domain=https://your-infisical-instance.com --email=admin@example.com --password=your-secure-password --organization=your-org-name | jq -r ".identity.credentials.token") + +# Now use the token for further automation +echo "Token has been captured and can be used for authentication" +``` + +## Notes + +- The bootstrap process can only be performed once on a fresh Infisical instance +- All flags are required for the bootstrap process to complete successfully +- Security controls prevent privilege escalation: instance admin identities cannot be managed by non-instance admin users and identities +- The generated admin user account can be used to log in via the UI if needed diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 2dff5cf7b..d1f4baa9f 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -219,6 +219,21 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` + + Used to set secrets from a file, supporting both `.env` and `YAML` formats. The file path can be either absolute or relative to the current working directory. + + The file should contain secrets in the following formats: + - `key=value` for `.env` files + - `key: value` for YAML files + + Comments can be written using `# comment` or `// comment`. Empty lines will be ignored during processing. + + + ```bash + # Example + infisical secrets set --file="./.env" + ``` + diff --git a/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-1.png b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-1.png new file mode 100644 index 000000000..e84a258e2 Binary files /dev/null and b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-1.png differ diff --git a/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-2.png b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-2.png new file mode 100644 index 000000000..824aabc25 Binary files /dev/null and b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-2.png differ diff --git a/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-3.png b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-3.png new file mode 100644 index 000000000..88d9a3da2 Binary files /dev/null and b/docs/documentation/platform/access-controls/abac/images/add-metadata-on-machine-identity-3.png differ diff --git a/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx new file mode 100644 index 000000000..5e1cc7093 --- /dev/null +++ b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx @@ -0,0 +1,68 @@ +--- +title: "Machine identities" +description: "Learn how to set metadata and leverage authentication attributes for machine identities." +--- + +Machine identities can have metadata set manually, just like users. In addition, during the machine authentication process (e.g., via OIDC), extra attributes called claims—are provided, which can be used in your ABAC policies. + +#### Setting Metadata on Machine Identities + + + + + + + + + + + + + + + + + +#### Accessing Attributes From Machine Identity Login + +When machine identities authenticate, they may receive additional payloads/attributes from the service provider. +For methods like OIDC, these come as claims in the token and can be made available in your policies. + + + + 1. Navigate to the Identity Authentication settings and select the OIDC Auth Method. + 2. In the **Advanced section**, locate the Claim Mapping configuration. + 3. Map the OIDC claims to permission attributes by specifying: + - **Attribute Name:** The identifier to be used in your policies (e.g., department). + - **Claim Path:** The dot notation path to the claim in the OIDC token (e.g., user.department). + + For example, if your OIDC provider returns: + + ```json + { + "sub": "machine456", + "name": "Service A", + "user": { + "department": "engineering", + "role": "service" + } + } + ``` + + You might map: + + - **department:** to `user.department` + - **role:** to `user.role` + + Once configured, these attributes become available in your policies using the following format: + + ``` + {{ identity.auth.oidc.claims. }} + ``` + + + + + At the moment we only support OIDC claims. Payloads on other authentication methods are not yet accessible. + + diff --git a/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx b/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx new file mode 100644 index 000000000..3f62a3b61 --- /dev/null +++ b/docs/documentation/platform/access-controls/abac/managing-user-metadata.mdx @@ -0,0 +1,39 @@ +--- +title: "Users identities" +description: "How to set and use metadata attributes on user identities for ABAC." +--- + +User identities can have metadata attributes assigned directly. These attributes (such as location or department) are used to define dynamic access policies. + +#### Setting Metadata on Users + + + + + + + + + + + + + + + + + For organizations using SAML for **user logins**, Infisical automatically maps metadata attributes from SAML assertions to user identities on every login. This enables dynamic policies based on the user's SAML attributes. + + + +#### Applying ABAC Policies with User Metadata +Attribute-based access controls are currently only available for polices defined on Secrets Manager projects. +You can set ABAC permissions to dynamically set access to environments, folders, secrets, and secret tags. + + + +In your policies, metadata values are accessed as follows: + +- **User ID:** `{{ identity.id }}` (always available) +- **Username:** `{{ identity.username }}` (always available) +- **Metadata Attributes:** `{{ identity.metadata. }}` (available if set) diff --git a/docs/documentation/platform/access-controls/abac/overview.mdx b/docs/documentation/platform/access-controls/abac/overview.mdx new file mode 100644 index 000000000..0a14719f6 --- /dev/null +++ b/docs/documentation/platform/access-controls/abac/overview.mdx @@ -0,0 +1,15 @@ +--- +title: "Overview" +description: "Learn the basics of ABAC for both users and machine identities." +--- + +Infisical's Attribute-based Access Controls (ABAC) enable dynamic, attribute-driven permissions for both users and machine identities. ABAC enforces fine-grained, context-aware access controls using metadata attributes—stored as key-value pairs—either attached to identities or provided during authentication. + + + + Manage user metadata manually or automatically via SAML logins. + + + Set metadata manually like users and access additional attributes provided during machine authentication (for example, OIDC claims). + + \ No newline at end of file diff --git a/docs/documentation/platform/access-controls/attribute-based-access-controls.mdx b/docs/documentation/platform/access-controls/attribute-based-access-controls.mdx deleted file mode 100644 index 99c49c63a..000000000 --- a/docs/documentation/platform/access-controls/attribute-based-access-controls.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Attribute-based Access Controls" -description: "Learn how to use ABAC to manage permissions based on identity attributes." ---- - -Infisical's Attribute-based Access Controls (ABAC) allow for dynamic, attribute-driven permissions for both user and machine identities. -ABAC policies use metadata attributes—stored as key-value pairs on identities—to enforce fine-grained permissions that are context aware. - -In ABAC, access controls are defined using metadata attributes, such as location or department, which can be set directly on user or machine identities. -During policy execution, these attributes are evaluated, and determine whether said actor can access the requested resource or perform the requested operation. - -## Project-level Permissions - -Attribute-based access controls are currently available for polices defined on projects. You can set ABAC permissions to control access to environments, folders, secrets, and secret tags. - -### Setting Metadata on Identities - - - - - - - - - - - - - - - - - For organizations using SAML for login, Infisical automatically maps metadata attributes from SAML assertions to user identities. - This makes it easy to create policies that dynamically adapt based on the SAML user’s attributes. - - - - -## Defining ABAC Policies - - - -ABAC policies make use of identity metadata to define dynamic permissions. Each attribute must start and end with double curly-brackets `{{ }}`. -The following attributes are available within project permissions: - -- **User ID**: `{{ identity.id }}` -- **Username**: `{{ identity.username }}` -- **Metadata Attributes**: `{{ identity.metadata. }}` - -During policy execution, these placeholders are replaced by their actual values prior to evaluation. - -### Example Use Case - -#### Location-based Access Control - -Suppose you want to restrict access to secrets within a specific folder based on a user's geographic region. -You could assign a `location` attribute to each user (e.g., `identity.metadata.location`). -You could then structure your folders to align with this attribute and define permissions accordingly. - -For example, a policy might restrict access to folders matching the user's location attribute in the following pattern: -``` -/appA/{{ identity.metadata.location }} -``` -Using this structure, users can only access folders that correspond to their configured `location` attribute. -Consequently, if a users attribute changes due to relocation, no policies need to be changed to gain access to the folders associated with their new location. diff --git a/docs/documentation/platform/access-controls/overview.mdx b/docs/documentation/platform/access-controls/overview.mdx index 552117c7e..0afaaac54 100644 --- a/docs/documentation/platform/access-controls/overview.mdx +++ b/docs/documentation/platform/access-controls/overview.mdx @@ -18,7 +18,7 @@ To make sure that users and machine identities are only accessing the resources diff --git a/docs/documentation/platform/audit-logs.mdx b/docs/documentation/platform/audit-logs.mdx index 594c1f707..7059fad4a 100644 --- a/docs/documentation/platform/audit-logs.mdx +++ b/docs/documentation/platform/audit-logs.mdx @@ -9,20 +9,76 @@ description: "Track evert event action performed within Infisical projects." If you're using Infisical Cloud, then it is available under the **Pro**, and **Enterprise Tier** with varying retention periods. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. + Infisical provides audit logs for security and compliance teams to monitor information access. With the Audit Log functionality, teams can: + - **Track** 40+ different events; - **Filter** audit logs by event, actor, source, date or any combination of these filters; - **Inspect** extensive metadata in the event of any suspicious activity or incident review. ![Audit logs](../../images/platform/audit-logs/audit-logs-table.png) +## Audit Log Structure + Each log contains the following data: -- **Event**: The underlying action such as create, list, read, update, or delete secret(s). -- **Actor**: The entity responsible for performing or causing the event; this can be a user or service. -- **Timestamp**: The date and time at which point the event occurred. -- **Source** (User agent + IP): The software (user agent) and network address (IP) from which the event was initiated. -- **Metadata**: Additional data to provide context for each event. For example, this could be the path at which a secret was fetched from etc. +| Field | Type | Description | Purpose | +| ------------------------- | -------- | --------------------------------------------------------- | ------------------------------------------------------------- | +| **event** | Object | Contains details about the action performed | Captures what happened | +| event.type | String | The specific action that occurred (e.g., "create-secret") | Identifies the exact operation | +| event.metadata | Object | Context-specific details about the event | Provides detailed information relevant to the specific action | +| **actor** | Object | Information about who performed the action | Identifies the responsible entity | +| actor.type | String | Category of actor (user, service, identity, etc.) | Distinguishes between human and non-human actors | +| actor.metadata | Object | Details about the specific actor | Provides identity information | +| actor.metadata.userId | String | Unique identifier for user actors | Links to specific user account | +| actor.metadata.email | String | Email address for user actors | Email of the executing user | +| actor.metadata.username | String | Username for user actors | Username of the executing user | +| actor.metadata.serviceId | String | Identifier for service actors | ID of specific service token | +| actor.metadata.identityId | String | Identifier for identity actors | ID to specific identity | +| actor.metadata.permission | Object | Permission context for the action | Shows permission template data when action was performed | +| **orgId** | String | Organization identifier | Indicates which organization the action occurred in | +| **projectId** | String | Project identifier | Indicates which project the action affected | +| **ipAddress** | String | Source IP address | Shows where the request originated from | +| **userAgent** | String | Client application information | Identifies browser or application used | +| **userAgentType** | String | Category of client (web, CLI, SDK, etc.) | Classifies the access method | +| **timestamp** | DateTime | When the action occurred | Records the exact time of the event | + + +```json +{ + "id": "[UUID]", + "ipAddress": "[IP_ADDRESS]", + "userAgent": "[USER_AGENT_STRING]", + "userAgentType": "web", + "expiresAt": "[TIMESTAMP]", + "createdAt": "[TIMESTAMP]", + "updatedAt": "[TIMESTAMP]", + "orgId": "[ORGANIZATION_UUID]", + "projectId": "[PROJECT_UUID]", + "projectName": "[PROJECT_NAME]", + "event": { + "type": "get-secrets", + "metadata": { + "secretPath": "[PATH]", + "environment": "[ENVIRONMENT_NAME]", + "numberOfSecrets": [NUMBER] + } + }, + "actor": { + "type": "user", + "metadata": { + "email": "[EMAIL]", + "userId": "[USER_UUID]", + "username": "[USERNAME]", + "permission": { + "metadata": {}, + "auth": {} + } + } + } +} +``` + diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 597978093..4d66e30b4 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -114,6 +114,13 @@ using the Universal Auth authentication method. that is to exchange the **Client ID** and **Client Secret** of the identity for an access token by making a request to the `/api/v1/auth/universal-auth/login` endpoint. + + Choose the correct base URL based on your region: + + - For Infisical Cloud US users: `https://app.infisical.com` + - For Infisical Cloud EU users: `https://eu.infisical.com` + + #### Sample request ```bash Request diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index 4f9efe49f..633377b3d 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -66,7 +66,7 @@ For organizations that work with US government agencies, FIPS compliance is almo - Are you using Docker? If you are using Docker, please follow the instructions in the [Using HSM's with Docker](#using-hsms-with-docker) section. + Are you using Docker or Kubernetes for your deployment? If you are using Docker or Kubernetes, please follow the instructions in the [Using HSM's in your Deployment](#using-hsms-in-your-deployment) section. Configuring the HSM on Infisical requires setting a set of environment variables: @@ -94,165 +94,447 @@ For organizations that work with US government agencies, FIPS compliance is almo -## Using HSMs with Docker -When using Docker, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Docker. +## Using HSMs In Your Deployment + - - - - When using Docker, you are able to set your HSM library path to any location on your machine. In this example, we are going to be using `/etc/luna-docker`. + + When using Docker, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Docker. - ```bash - mkdir /etc/luna-docker - ``` + + + + + When using Docker, you are able to set your HSM library path to any location on your machine. In this example, we are going to be using `/etc/luna-docker`. - After [setting up your Luna Cloud HSM client](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/add_dpod.htm), you should have a set of files, referred to as the HSM client. You don't need all the files, but for simplicity we recommend copying all the files from the client. + ```bash + mkdir /etc/luna-docker + ``` - A folder structure of a client folder will often look like this: - ``` - partition-ca-certificate.pem - partition-certificate.pem - server-certificate.pem - Chrystoki.conf - /plugins - libcloud.plugin - /lock - /libs - /64 - libCryptoki2.so - /jsp - LunaProvider.jar - /64 - libLunaAPI.so - /etc - openssl.cnf - /bin - /64 - ckdemo - lunacm - multitoken - vtl - ``` - - The most important parts of the client folder is the `Chrystoki.conf` file, and the `libs`, `plugins`, and `jsp` folders. You need to copy these files to the folder you created in the first step. + After [setting up your Luna Cloud HSM client](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/add_dpod.htm), you should have a set of files, referred to as the HSM client. You don't need all the files, but for simplicity we recommend copying all the files from the client. - ```bash - cp -r / /etc/luna-docker - ``` + A folder structure of a client folder will often look like this: + ``` + partition-ca-certificate.pem + partition-certificate.pem + server-certificate.pem + Chrystoki.conf + /plugins + libcloud.plugin + /lock + /libs + /64 + libCryptoki2.so + /jsp + LunaProvider.jar + /64 + libLunaAPI.so + /etc + openssl.cnf + /bin + /64 + ckdemo + lunacm + multitoken + vtl + ``` + + The most important parts of the client folder is the `Chrystoki.conf` file, and the `libs`, `plugins`, and `jsp` folders. You need to copy these files to the folder you created in the first step. - + ```bash + cp -r / /etc/luna-docker + ``` - - The `Chrystoki.conf` file is used to configure the HSM client. You need to update the `Chrystoki.conf` file to point to the correct file paths. + - In this example, we will be mounting the `/etc/luna-docker` folder to the Docker container under a different path. The path we will use in this example is `/usr/safenet/lunaclient`. This means `/etc/luna-docker` will be mounted to `/usr/safenet/lunaclient` in the Docker container. + + The `Chrystoki.conf` file is used to configure the HSM client. You need to update the `Chrystoki.conf` file to point to the correct file paths. - An example config file will look like this: + In this example, we will be mounting the `/etc/luna-docker` folder to the Docker container under a different path. The path we will use in this example is `/usr/safenet/lunaclient`. This means `/etc/luna-docker` will be mounted to `/usr/safenet/lunaclient` in the Docker container. - ```Chrystoki.conf - Chrystoki2 = { - # This path points to the mounted path, /usr/safenet/lunaclient - LibUNIX64 = /usr/safenet/lunaclient/libs/64/libCryptoki2.so; - } + An example config file will look like this: - Luna = { - DefaultTimeOut = 500000; - PEDTimeout1 = 100000; - PEDTimeout2 = 200000; - PEDTimeout3 = 20000; - KeypairGenTimeOut = 2700000; - CloningCommandTimeOut = 300000; - CommandTimeOutPedSet = 720000; - } + ```Chrystoki.conf + Chrystoki2 = { + # This path points to the mounted path, /usr/safenet/lunaclient + LibUNIX64 = /usr/safenet/lunaclient/libs/64/libCryptoki2.so; + } - CardReader = { - LunaG5Slots = 0; - RemoteCommand = 1; - } + Luna = { + DefaultTimeOut = 500000; + PEDTimeout1 = 100000; + PEDTimeout2 = 200000; + PEDTimeout3 = 20000; + KeypairGenTimeOut = 2700000; + CloningCommandTimeOut = 300000; + CommandTimeOutPedSet = 720000; + } - Misc = { - # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. - PluginModuleDir = /usr/safenet/lunaclient/plugins; - MutexFolder = /usr/safenet/lunaclient/lock; - PE1746Enabled = 1; - ToolsDir = /usr/bin; + CardReader = { + LunaG5Slots = 0; + RemoteCommand = 1; + } - } + Misc = { + # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. + PluginModuleDir = /usr/safenet/lunaclient/plugins; + MutexFolder = /usr/safenet/lunaclient/lock; + PE1746Enabled = 1; + ToolsDir = /usr/bin; - Presentation = { - ShowEmptySlots = no; - } + } - LunaSA Client = { - ReceiveTimeout = 20000; - # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. - SSLConfigFile = /usr/safenet/lunaclient/etc/openssl.cnf; - ClientPrivKeyFile = ./etc/ClientNameKey.pem; - ClientCertFile = ./etc/ClientNameCert.pem; - ServerCAFile = ./etc/CAFile.pem; - NetClient = 1; - TCPKeepAlive = 1; - } + Presentation = { + ShowEmptySlots = no; + } + + LunaSA Client = { + ReceiveTimeout = 20000; + # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. + SSLConfigFile = /usr/safenet/lunaclient/etc/openssl.cnf; + ClientPrivKeyFile = ./etc/ClientNameKey.pem; + ClientCertFile = ./etc/ClientNameCert.pem; + ServerCAFile = ./etc/CAFile.pem; + NetClient = 1; + TCPKeepAlive = 1; + } - REST = { - AppLogLevel = error - ServerName = ; - ServerPort = 443; - AuthTokenConfigURI = ; - AuthTokenClientId = ; - AuthTokenClientSecret = ; - RestClient = 1; - ClientTimeoutSec = 120; - ClientPoolSize = 32; - ClientEofRetryCount = 15; - ClientConnectRetryCount = 900; - ClientConnectIntervalMs = 1000; - } - XTC = { - Enabled = 1; - TimeoutSec = 600; - } - ``` + REST = { + AppLogLevel = error + ServerName = ; + ServerPort = 443; + AuthTokenConfigURI = ; + AuthTokenClientId = ; + AuthTokenClientSecret = ; + RestClient = 1; + ClientTimeoutSec = 120; + ClientPoolSize = 32; + ClientEofRetryCount = 15; + ClientConnectRetryCount = 900; + ClientConnectIntervalMs = 1000; + } + XTC = { + Enabled = 1; + TimeoutSec = 600; + } + ``` - Save the file after updating the paths. - + Save the file after updating the paths. + - - Running Docker with HSM encryption requires setting the HSM-related environment variables as mentioned previously in the [HSM setup instructions](#setup-instructions). You can set these environment variables in your Docker run command. + + Running Docker with HSM encryption requires setting the HSM-related environment variables as mentioned previously in the [HSM setup instructions](#setup-instructions). You can set these environment variables in your Docker run command. - We are setting the environment variables for Docker via the command line in this example, but you can also pass in a `.env` file to set these environment variables. + We are setting the environment variables for Docker via the command line in this example, but you can also pass in a `.env` file to set these environment variables. - - If no key is found with the provided key label, the HSM will create a new key with the provided label. - Infisical depends on an AES and HMAC key to be present in the HSM. If these keys are not present, Infisical will create them. The AES key label will be the value of the `HSM_KEY_LABEL` environment variable, and the HMAC key label will be the value of the `HSM_KEY_LABEL` environment variable with the suffix `_HMAC`. - + + If no key is found with the provided key label, the HSM will create a new key with the provided label. + Infisical depends on an AES and HMAC key to be present in the HSM. If these keys are not present, Infisical will create them. The AES key label will be the value of the `HSM_KEY_LABEL` environment variable, and the HMAC key label will be the value of the `HSM_KEY_LABEL` environment variable with the suffix `_HMAC`. + - ```bash - docker run -p 80:8080 \ - -v /etc/luna-docker:/usr/safenet/lunaclient \ - -e HSM_LIB_PATH="/usr/safenet/lunaclient/libs/64/libCryptoki2.so" \ - -e HSM_PIN="" \ - -e HSM_SLOT= \ - -e HSM_KEY_LABEL="" \ - - # The rest are unrelated to HSM setup... - -e ENCRYPTION_KEY="<>" \ - -e AUTH_SECRET="<>" \ - -e DB_CONNECTION_URI="<>" \ - -e REDIS_URL="<>" \ - -e SITE_URL="<>" \ - infisical/infisical-fips: # Replace with the version you want to use - ``` + ```bash + docker run -p 80:8080 \ + -v /etc/luna-docker:/usr/safenet/lunaclient \ + -e HSM_LIB_PATH="/usr/safenet/lunaclient/libs/64/libCryptoki2.so" \ + -e HSM_PIN="" \ + -e HSM_SLOT= \ + -e HSM_KEY_LABEL="" \ + + # The rest are unrelated to HSM setup... + -e ENCRYPTION_KEY="<>" \ + -e AUTH_SECRET="<>" \ + -e DB_CONNECTION_URI="<>" \ + -e REDIS_URL="<>" \ + -e SITE_URL="<>" \ + infisical/infisical-fips: # Replace with the version you want to use + ``` - We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). + We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). - - - After following these steps, your Docker setup will be ready to use HSM encryption. + + + After following these steps, your Docker setup will be ready to use HSM encryption. + + + + + When you are deploying Infisical with the [Kubernetes self-hosting option](/self-hosting/deployment-options/kubernetes-helm), you can still use HSM encryption, but you need to ensure that the HSM client files are present in the container. + + + + + This is only supported on helm chart version `1.4.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. + + + + + When using Kubernetes, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Kubernetes. + + + ```bash + mkdir /etc/hsm-client + ``` + + After [setting up your Luna Cloud HSM client](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/add_dpod.htm), you should have a set of files, referred to as the HSM client. You don't need all the files, but for simplicity we recommend copying all the files from the client. + + A folder structure of a client folder will often look like this: + ``` + partition-ca-certificate.pem + partition-certificate.pem + server-certificate.pem + Chrystoki.conf + /plugins + libcloud.plugin + /lock + /libs + /64 + libCryptoki2.so + /jsp + LunaProvider.jar + /64 + libLunaAPI.so + /etc + openssl.cnf + /bin + /64 + ckdemo + lunacm + multitoken + vtl + ``` + + The most important parts of the client folder is the `Chrystoki.conf` file, and the `libs`, `plugins`, and `jsp` folders. You need to copy these files to the folder you created in the first step. + + ```bash + cp -r / /etc/hsm-client + ``` + + + The `Chrystoki.conf` file is used to configure the HSM client. You need to update the `Chrystoki.conf` file to point to the correct file paths. + + In this example, we will be mounting the `/etc/hsm-client` folder from the host to containers in our deployment's pods at the path `/hsm-client`. This means the contents of `/etc/hsm-client` on the host will be accessible at `/hsm-client` within the containers. + + An example config file will look like this: + + ```Chrystoki.conf + Chrystoki2 = { + # This path points to the mounted path, /hsm-client + LibUNIX64 = /hsm-client/libs/64/libCryptoki2.so; + } + + Luna = { + DefaultTimeOut = 500000; + PEDTimeout1 = 100000; + PEDTimeout2 = 200000; + PEDTimeout3 = 20000; + KeypairGenTimeOut = 2700000; + CloningCommandTimeOut = 300000; + CommandTimeOutPedSet = 720000; + } + + CardReader = { + LunaG5Slots = 0; + RemoteCommand = 1; + } + + Misc = { + # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. + PluginModuleDir = /hsm-client/plugins; + MutexFolder = /hsm-client/lock; + PE1746Enabled = 1; + ToolsDir = /usr/bin; + + } + + Presentation = { + ShowEmptySlots = no; + } + + LunaSA Client = { + ReceiveTimeout = 20000; + # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. + SSLConfigFile = /hsm-client/etc/openssl.cnf; + ClientPrivKeyFile = ./etc/ClientNameKey.pem; + ClientCertFile = ./etc/ClientNameCert.pem; + ServerCAFile = ./etc/CAFile.pem; + NetClient = 1; + TCPKeepAlive = 1; + } + + + REST = { + AppLogLevel = error + ServerName = ; + ServerPort = 443; + AuthTokenConfigURI = ; + AuthTokenClientId = ; + AuthTokenClientSecret = ; + RestClient = 1; + ClientTimeoutSec = 120; + ClientPoolSize = 32; + ClientEofRetryCount = 15; + ClientConnectRetryCount = 900; + ClientConnectIntervalMs = 1000; + } + XTC = { + Enabled = 1; + TimeoutSec = 600; + } + ``` + + Save the file after updating the paths. + + + + You need to create a Persistent Volume Claim (PVC) to mount the HSM client files to the Infisical deployment. + + + ```bash + kubectl apply -f - < + + + Next we need to update the environment variables used for the deployment. If you followed the [setup instructions for Kubernetes deployments](/self-hosting/deployment-options/kubernetes-helm), you should have a Kubernetes secret called `infisical-secrets`. + We need to update the secret with the following environment variables: + + - `HSM_LIB_PATH` - The path to the HSM client library _(mapped to `/hsm-client/libs/64/libCryptoki2.so`)_ + - `HSM_PIN` - The PIN for the HSM device that you created when setting up your Luna Cloud HSM client + - `HSM_SLOT` - The slot number for the HSM device that you selected when setting up your Luna Cloud HSM client + - `HSM_KEY_LABEL` - The label for the HSM key. If no key is found with the provided key label, the HSM will create a new key with the provided label. + + The following is an example of the secret that you should update: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + # ... Other environment variables ... + HSM_LIB_PATH: "/hsm-client/libs/64/libCryptoki2.so" # If you followed this guide, this will be the path of the Luna Cloud HSM client + HSM_PIN: "" + HSM_SLOT: "" + HSM_KEY_LABEL: "" + ``` + + Save the file after updating the environment variables, and apply the secret changes + + ```bash + kubectl apply -f ./secret-file-name.yaml + ``` + + + + After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. + + We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with support for HSM encryption. + + ```yaml + # ... The rest of the values.yaml file ... + + image: + repository: infisical/infisical-fips # Very important: Must use "infisical/infisical-fips" + tag: "v0.117.1-postgres" + pullPolicy: IfNotPresent + + extraVolumeMounts: + - name: hsm-data + mountPath: /hsm-client # The path we will mount the HSM client files to + subPath: ./hsm-client + + extraVolumes: + - name: hsm-data + persistentVolumeClaim: + claimName: infisical-data-pvc # The PVC we created in the previous step + + # ... The rest of the values.yaml file ... + ``` + + + + + + After updating the values.yaml file, you need to upgrade the Helm chart in order for the changes to take effect. + + ```bash + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + + After upgrading the Helm chart, you need to restart the deployment in order for the changes to take effect. + + ```bash + kubectl rollout restart deployment/infisical-infisical + ``` + + + After following these steps, your Kubernetes setup will be ready to use HSM encryption. + + + ## Disabling HSM Encryption To disable HSM encryption, navigate to Infisical's Server Admin Console and set the KMS encryption strategy to `Software-based Encryption`. This will revert the encryption strategy back to the default software-based encryption. diff --git a/docs/images/platform/access-controls/abac-policies-by-auth.png b/docs/images/platform/access-controls/abac-policies-by-auth.png new file mode 100644 index 000000000..3c75aabbe Binary files /dev/null and b/docs/images/platform/access-controls/abac-policies-by-auth.png differ diff --git a/docs/images/platform/access-controls/abac-policy-oidc-format.png b/docs/images/platform/access-controls/abac-policy-oidc-format.png new file mode 100644 index 000000000..7fc76f50b Binary files /dev/null and b/docs/images/platform/access-controls/abac-policy-oidc-format.png differ diff --git a/docs/images/platform/audit-logs/audit-logs-table.png b/docs/images/platform/audit-logs/audit-logs-table.png index a24b02bc0..ff8168aa5 100644 Binary files a/docs/images/platform/audit-logs/audit-logs-table.png and b/docs/images/platform/audit-logs/audit-logs-table.png differ diff --git a/docs/images/self-hosting/guides/automated-bootstrapping/identity-instance-admin.png b/docs/images/self-hosting/guides/automated-bootstrapping/identity-instance-admin.png new file mode 100644 index 000000000..8d819e1fb Binary files /dev/null and b/docs/images/self-hosting/guides/automated-bootstrapping/identity-instance-admin.png differ diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 40a1a19b5..898a77b00 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -6,7 +6,7 @@ description: "Learn how to fetch secrets from Infisical with Terraform using bot This guide demonstrates how to use Infisical to manage secrets in your Terraform infrastructure code, supporting both traditional data sources and ephemeral resources for enhanced security. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets -- The [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest) to fetch secrets for your infrastructure +- The [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) to fetch secrets for your infrastructure ## Prerequisites diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 6f0a2517a..17daf88cb 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -103,7 +103,7 @@ With standby regions and automated failovers in place, Infisical Cloud faces min Infisical hires external third parties to perform regular security assessment and penetration testing of the platform. -Most recently, Infisical commissioned cybersecurity firm [Oneleet](https://www.oneleet.com) to perform a full-coverage, gray box penetration test against the platform's entire attack surface to identify vulnerabilities according to industry standards (OWASP, ASVS, WSTG, TOP-10, etc.). +Most recently, Infisical commissioned cybersecurity firm [Cure53](https://cure53.de/) to perform a full-coverage, gray box penetration test against the platform's entire attack surface to identify vulnerabilities according to industry standards (OWASP, ASVS, WSTG, TOP-10, etc.). Please email security@infisical.com to request any reports including a letter of attestation for the conducted penetration test. diff --git a/docs/mint.json b/docs/mint.json index 8f4c22bd1..76bea0295 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -150,7 +150,14 @@ "pages": [ "documentation/platform/access-controls/overview", "documentation/platform/access-controls/role-based-access-controls", - "documentation/platform/access-controls/attribute-based-access-controls", + { + "group": "Attribute based access controls", + "pages": [ + "documentation/platform/access-controls/abac/overview", + "documentation/platform/access-controls/abac/managing-user-metadata", + "documentation/platform/access-controls/abac/managing-machine-identity-attributes" + ] + }, "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", @@ -311,7 +318,8 @@ "group": "Guides", "pages": [ "self-hosting/guides/mongo-to-postgres", - "self-hosting/guides/custom-certificates" + "self-hosting/guides/custom-certificates", + "self-hosting/guides/automated-bootstrapping" ] }, { @@ -341,6 +349,7 @@ "cli/commands/dynamic-secrets", "cli/commands/ssh", "cli/commands/gateway", + "cli/commands/bootstrap", "cli/commands/export", "cli/commands/token", "cli/commands/service-token", diff --git a/docs/self-hosting/guides/automated-bootstrapping.mdx b/docs/self-hosting/guides/automated-bootstrapping.mdx new file mode 100644 index 000000000..ebc9c3c80 --- /dev/null +++ b/docs/self-hosting/guides/automated-bootstrapping.mdx @@ -0,0 +1,150 @@ +--- +title: "Programmatic Provisioning" +description: "Learn how to provision and configure Infisical instances programmatically without UI interaction" +--- + +Infisical's Automated Bootstrapping feature enables you to provision and configure an Infisical instance without using the UI, allowing for complete automation through static configuration files, API calls, or CLI commands. This is especially valuable for enterprise environments where automated deployment and infrastructure-as-code practices are essential. + +## Overview + +The Automated Bootstrapping workflow automates the following processes: +- Creating an admin user account +- Initializing an organization for the entire instance +- Establishing an **instance admin machine identity** with full administrative permissions +- Returning the machine identity credentials for further automation + +## Key Concepts + +- **Instance Initialization**: Infisical requires [configuration variables](/self-hosting/configuration/envars) to be set during launch, after which the bootstrap process can be triggered. +- **Instance Admin Machine Identity**: The bootstrapping process creates a machine identity with instance-level admin privileges, which can be used to programmatically manage all aspects of the Infisical instance. + ![Instance Admin Identity](/images/self-hosting/guides/automated-bootstrapping/identity-instance-admin.png) +- **Token Auth**: The instance admin machine identity uses [Token Auth](/documentation/platform/identities/token-auth), providing a JWT token that can be used directly to make authenticated requests to the Infisical API. + +## Prerequisites + +- An Infisical instance launched with all required configuration variables +- Access to the Infisical CLI or the ability to make API calls to the instance +- Network connectivity to the Infisical instance + +## Bootstrap Methods + +You can bootstrap an Infisical instance using either the API or the CLI. + + + + Make a POST request to the bootstrap endpoint: + + ``` + POST: http://your-infisical-instance.com/api/v1/admin/bootstrap + { + "email": "admin@example.com", + "password": "your-secure-password", + "organization": "your-org-name" + } + ``` + + Example using curl: + + ```bash + curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"your-secure-password","organization":"your-org-name"}' \ + http://your-infisical-instance.com/api/v1/admin/bootstrap + ``` + + + Use the [Infisical CLI](/cli/commands/bootstrap) to bootstrap the instance and extract the token for immediate use in automation: + + ```bash + infisical bootstrap --domain="http://localhost:8080" --email="admin@example.com" --password="your-secure-password" --organization="your-org-name" | jq ".identity.credentials.token" + ``` + + This example command pipes the output through `jq` to extract only the machine identity token, making it easy to capture and use directly in automation scripts or export as an environment variable for tools like Terraform. + + + +## API Response Structure + +The bootstrap process returns a JSON response with details about the created user, organization, and machine identity: + +```json +{ + "identity": { + "credentials": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiZGIyMjQ3OTItZWQxOC00Mjc3LTlkYWUtNTdlNzUyMzE1ODU0IiwiaWRlbnRpdHlBY2Nlc3NUb2tlbklkIjoiZmVkZmZmMGEtYmU3Yy00NjViLWEwZWEtZjM5OTNjMTg4OGRlIiwiYXV0aFRva2VuVHlwZSI6ImlkZW50aXR5QWNjZXNzVG9rZW4iLCJpYXQiOjE3NDIzMjI0ODl9.mqcZZqIFqER1e9ubrQXp8FbzGYi8nqqZwfMvz09g-8Y" + }, + "id": "db224792-ed18-4277-9dae-57e752315854", + "name": "Instance Admin Identity" + }, + "message": "Successfully bootstrapped instance", + "organization": { + "id": "b56bece0-42f5-4262-b25e-be7bf5f84957", + "name": "dog", + "slug": "dog-v-e5l" + }, + "user": { + "email": "admin@example.com", + "firstName": "Admin", + "id": "a418f355-c8da-453c-bbc8-6c07208eeb3c", + "lastName": "User", + "superAdmin": true, + "username": "admin@example.com" + } +} +``` + +## Using the Instance Admin Machine Identity Token + +The bootstrap process automatically creates a machine identity with Token Auth configured. The returned token has instance-level admin privileges (the highest level of access) and should be treated with the same security considerations as a root credential. + +The token enables full programmatic control of your Infisical instance and can be used in the following ways: + +### 1. Infrastructure Automation + +Store the token securely for use with infrastructure automation tools. Due to the sensitive nature of this token, ensure it's protected using appropriate secret management practices: + +#### Kubernetes Secret (with appropriate RBAC restrictions) + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: infisical-admin-credentials +type: Opaque +data: + token: +``` + +#### Environment Variable for Terraform + +```bash +export INFISICAL_TOKEN=your-access-token +terraform apply +``` + +### 2. Programmatic Resource Management + +Use the token to authenticate API calls for creating and managing Infisical resources. The token works exactly like any other Token Auth access token in the Infisical API: + +```bash +curl -X POST \ + -H "Authorization: Bearer ${INFISICAL_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "projectName": "New Project", + "projectDescription": "A project created via API", + "slug": "new-project-slug", + "template": "default", + "type": "SECRET_MANAGER" + }' \ + https://your-infisical-instance.com/api/v2/projects +``` + +## Important Notes + +- **Security Warning**: The instance admin machine identity has the highest level of privileges in your Infisical deployment. The token should be treated with the utmost security and handled like a root credential. Unauthorized access to this token could compromise your entire Infisical instance. +- Security controls prevent privilege escalation: instance admin identities cannot be managed by non-instance admin users and identities +- The instance admin permission of the generated identity can be revoked later in the server admin panel if needed +- The generated admin user account can still be used for UI access if needed, or can be removed if you prefer to manage everything through the machine identity +- This process is designed to work with future Crossplane providers and the existing Terraform provider for full infrastructure-as-code capabilities +- All necessary configuration variables should be set during the initial launch of the Infisical instance diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7e9ca7e7..1fa72b81a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -362,26 +362,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.26.10" }, "bin": { "parser": "bin/babel-parser.js" @@ -423,9 +423,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -435,14 +435,14 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" @@ -476,9 +476,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -1010,6 +1010,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -1028,9 +1045,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", "cpu": [ "arm64" ], @@ -1647,12 +1664,12 @@ } }, "node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", + "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.0.0", + "@octokit/types": "^13.6.2", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1674,18 +1691,18 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.6.tgz", - "integrity": "sha512-zcvqqf/+TicbTCa/Z+3w4eBJcAxCFymtc0UAIsR3dEVoNilWld4oXdscQ3laXamTszUZdusw97K8+DrbFiOwjw==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^13.10.0" }, "engines": { "node": ">= 18" @@ -1722,14 +1739,15 @@ } }, "node_modules/@octokit/request": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", - "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.2.tgz", + "integrity": "sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==", "license": "MIT", "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.1.0", + "@octokit/endpoint": "^10.1.3", + "@octokit/request-error": "^6.1.7", + "@octokit/types": "^13.6.2", + "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1737,12 +1755,12 @@ } }, "node_modules/@octokit/request-error": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.5.tgz", - "integrity": "sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", + "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.0.0" + "@octokit/types": "^13.6.2" }, "engines": { "node": ">= 18" @@ -1764,12 +1782,12 @@ } }, "node_modules/@octokit/types": { - "version": "13.6.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.2.tgz", - "integrity": "sha512-WpbZfZUcZU77DrSW4wbsSgTPfKcp286q3ItaIgvSbBpZJlu6mnYXAkjZz6LVZPXkEvLIM8McanyZejKTYUHipA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@peculiar/asn1-cms": { @@ -4956,9 +4974,9 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", + "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5451,9 +5469,9 @@ "license": "CC-BY-4.0" }, "node_modules/canvg": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", - "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", "license": "MIT", "optional": true, "dependencies": { @@ -7314,6 +7332,22 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12588,13 +12622,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -12608,9 +12642,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], @@ -12625,9 +12659,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], @@ -12642,9 +12676,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], @@ -12659,9 +12693,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], @@ -12676,9 +12710,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -12693,9 +12727,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], @@ -12710,9 +12744,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], @@ -12727,9 +12761,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], @@ -12744,9 +12778,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], @@ -12761,9 +12795,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], @@ -12778,9 +12812,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], @@ -12795,9 +12829,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], @@ -12812,9 +12846,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], @@ -12829,9 +12863,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], @@ -12846,9 +12880,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], @@ -12863,9 +12897,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], @@ -12880,9 +12914,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], @@ -12897,9 +12931,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], @@ -12914,9 +12948,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], @@ -12931,9 +12965,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], @@ -12948,9 +12982,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], @@ -12965,9 +12999,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], @@ -12982,9 +13016,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], @@ -12999,9 +13033,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -13012,30 +13046,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/tsyringe": { @@ -13552,9 +13587,9 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 5eb6c6732..d43fbc080 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,7 +1,9 @@ export { useAdminDeleteUser, useAdminGrantServerAdminAccess, + useAdminRemoveIdentitySuperAdminAccess, useCreateAdminUser, + useRemoveUserServerAdminAccess, useUpdateAdminSlackConfig, useUpdateServerConfig, useUpdateServerEncryptionStrategy diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 901c079a0..b3e1e37b4 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -70,6 +70,40 @@ export const useAdminDeleteUser = () => { }); }; +export const useAdminRemoveIdentitySuperAdminAccess = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (identityId: string) => { + await apiRequest.delete( + `/api/v1/admin/identity-management/identities/${identityId}/super-admin-access` + ); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getIdentities] + }); + } + }); +}; + +export const useRemoveUserServerAdminAccess = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userId: string) => { + await apiRequest.delete(`/api/v1/admin/user-management/users/${userId}/admin-access`); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + } + }); +}; + export const useAdminGrantServerAdminAccess = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 86d35e086..00dd13d55 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/main.tsx b/frontend/src/main.tsx index 088d28250..779c21bd8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -22,14 +22,37 @@ import "./translation"; // have a look at the Quick start guide // for passing in lng and translations on init/ -// https://vite.dev/guide/build#load-error-handling -window.addEventListener("vite:preloadError", () => { - window.location.reload(); // for example, refresh the page -}); - // Create a new router instance NProgress.configure({ showSpinner: false }); +window.addEventListener("vite:preloadError", async (event) => { + event.preventDefault(); + // Get current count from session storage or initialize to 0 + const reloadCount = parseInt(sessionStorage.getItem("vitePreloadErrorCount") || "0", 10); + + // Check if we've already tried 3 times + if (reloadCount >= 2) { + console.warn("Vite preload has failed multiple times. Stopping automatic reload."); + // Optionally show a user-facing message here + return; + } + + try { + if ("caches" in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + } catch (cleanupError) { + console.error(cleanupError); + } + // + // Increment and save the counter + sessionStorage.setItem("vitePreloadErrorCount", (reloadCount + 1).toString()); + + console.log(`Reloading page (attempt ${reloadCount + 1} of 2)...`); + window.location.reload(); // for example, refresh the page +}); + const router = createRouter({ routeTree, context: { serverConfig: null, queryClient }, diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 8d1e25bc5..3e6ca67f6 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -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 ?? "" } }); @@ -165,8 +165,8 @@ export const OverviewPage = () => { Authentication Rate Limit Integrations - Users - Identities + User Identities + Machine Identities diff --git a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx index ee2166a4e..1df9a7cd3 100644 --- a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx @@ -1,9 +1,16 @@ import { useState } from "react"; -import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; +import { faEllipsis, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createNotification } from "@app/components/notifications"; import { + Badge, Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, Input, Table, @@ -15,10 +22,22 @@ import { THead, Tr } from "@app/components/v2"; -import { useDebounce } from "@app/hooks"; +import { useDebounce, usePopUp } from "@app/hooks"; +import { useAdminRemoveIdentitySuperAdminAccess } from "@app/hooks/api/admin"; import { useAdminGetIdentities } from "@app/hooks/api/admin/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; -const IdentityPanelTable = () => { +const IdentityPanelTable = ({ + handlePopUpOpen +}: { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeServerAdmin"]>, + data?: { + name: string; + id: string; + } + ) => void; +}) => { const [searchIdentityFilter, setSearchIdentityFilter] = useState(""); const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500); @@ -48,15 +67,48 @@ const IdentityPanelTable = () => { Name + {isPending && } {!isPending && data?.pages?.map((identities) => - identities.map(({ name, id }) => ( + identities.map(({ name, id, isInstanceAdmin }) => ( - {name} + + {name} + {isInstanceAdmin && ( + + Server Admin + + )} + + + {isInstanceAdmin && ( +
+ + +
+ +
+
+ + {isInstanceAdmin && ( + { + e.stopPropagation(); + handlePopUpOpen("removeServerAdmin", { name, id }); + }} + > + Remove Server Admin + + )} + +
+
+ )} + )) )} @@ -81,11 +133,49 @@ const IdentityPanelTable = () => { ); }; -export const IdentityPanel = () => ( -
-
-

Identities

+export const IdentityPanel = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeServerAdmin" + ] as const); + + const { mutate: deleteIdentitySuperAdminAccess } = useAdminRemoveIdentitySuperAdminAccess(); + + const handleRemoveServerAdmin = async () => { + const { id } = popUp?.removeServerAdmin?.data as { id: string; name: string }; + + try { + await deleteIdentitySuperAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully removed server admin permissions" + }); + } catch { + createNotification({ + type: "error", + text: "Error removing server admin permissions" + }); + } + + handlePopUpClose("removeServerAdmin"); + }; + + return ( +
+
+

Identities

+
+ + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdmin} + buttonText="Remove Access" + />
- -
-); + ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index 84d0ed6f0..9395a9a8a 100644 --- a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx @@ -33,22 +33,26 @@ import { THead, Tr } from "@app/components/v2"; -import { useSubscription, useUser } from "@app/context"; +import { useSubscription } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; import { useAdminDeleteUser, useAdminGetUsers, - useAdminGrantServerAdminAccess + useAdminGrantServerAdminAccess, + useRemoveUserServerAdminAccess } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; +const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; const UserPanelTable = ({ handlePopUpOpen }: { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeUser", "upgradePlan", "upgradeToServerAdmin"]>, + popUpName: keyof UsePopUpState< + ["removeUser", "upgradePlan", "upgradeToServerAdmin", "removeServerAdmin"] + >, data?: { username: string; id: string; @@ -58,8 +62,6 @@ const UserPanelTable = ({ }) => { const [searchUserFilter, setSearchUserFilter] = useState(""); const [adminsOnly, setAdminsOnly] = useState(false); - const { user } = useUser(); - const userId = user?.id || ""; const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const { subscription } = useSubscription(); @@ -143,45 +145,61 @@ const UserPanelTable = ({ {email} - {userId !== id && ( -
- - -
- -
-
- +
+ + +
+ +
+
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + > + Remove User + + {!superAdmin && ( { e.stopPropagation(); - handlePopUpOpen("removeUser", { username, id }); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: addServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("upgradeToServerAdmin", { username, id }); }} > - Remove User + Make User Server Admin - {!superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: addServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("upgradeToServerAdmin", { username, id }); - }} - > - Make User Server Admin - - )} - -
-
- )} + )} + {superAdmin && ( + { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + )} +
+
+
); @@ -212,11 +230,13 @@ export const UserPanel = () => { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "removeUser", "upgradePlan", - "upgradeToServerAdmin" + "upgradeToServerAdmin", + "removeServerAdmin" ] as const); const { mutateAsync: deleteUser } = useAdminDeleteUser(); const { mutateAsync: grantAdminAccess } = useAdminGrantServerAdminAccess(); + const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -256,6 +276,25 @@ export const UserPanel = () => { handlePopUpClose("upgradeToServerAdmin"); }; + const handleRemoveServerAdminAccess = async () => { + const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string }; + + try { + await removeAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully removed server admin access from user" + }); + } catch { + createNotification({ + type: "error", + text: "Error removing server admin access from user" + }); + } + + handlePopUpClose("removeServerAdmin"); + }; + return (
@@ -282,6 +321,17 @@ export const UserPanel = () => { onDeleteApproved={handleGrantServerAdminAccess} buttonText="Grant Access" /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx index 21440457a..c0a91a7f7 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -46,12 +46,14 @@ const schema = z.object({ caCert: z.string().trim().default(""), boundIssuer: z.string().min(1), boundAudiences: z.string().optional().default(""), - boundClaims: z.array( - z.object({ - key: z.string(), - value: z.string() - }) - ), + boundClaims: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .default([]), claimMetadataMapping: z .array( z.object({ @@ -59,8 +61,7 @@ const schema = z.object({ value: z.string() }) ) - .optional() - .nullable(), + .default([]), boundSubject: z.string().optional().default("") }); @@ -105,7 +106,9 @@ export const IdentityOidcAuthForm = ({ accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "0", - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + boundClaims: [], + claimMetadataMapping: [] } }); const { @@ -172,7 +175,8 @@ export const IdentityOidcAuthForm = ({ accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "0", - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + claimMetadataMapping: [] }); } }, [data]); @@ -253,7 +257,9 @@ export const IdentityOidcAuthForm = ({
{ setTabValue( - ["accessTokenTrustedIps", "caCert", "boundClaims"].includes(Object.keys(fields)[0]) + ["accessTokenTrustedIps", "caCert", "claimMetadataMapping"].includes( + Object.keys(fields)[0] + ) ? IdentityFormTab.Advanced : IdentityFormTab.Configuration ); @@ -343,63 +349,6 @@ export const IdentityOidcAuthForm = ({ )} /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - - - ( - -