mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-28 15:29:21 +00:00
Compare commits
56 Commits
feat/allow
...
feat/ENG-2
Author | SHA1 | Date | |
---|---|---|---|
08bac83bcc | |||
de5a432745 | |||
387780aa94 | |||
3887ce800b | |||
1a06b3e1f5 | |||
627e17b3ae | |||
39b7a4a111 | |||
e7c512999e | |||
c9da8477c8 | |||
5e4b478b74 | |||
17cf602a65 | |||
23f6f5dfd4 | |||
3986df8e8a | |||
3fcd84b592 | |||
29e39b558b | |||
9458c8b04f | |||
3b95c5d859 | |||
de8f315211 | |||
9960d58e1b | |||
0057404562 | |||
47ca1b3011 | |||
716cd090c4 | |||
e870bb3ade | |||
98c9e98082 | |||
a814f459ab | |||
66817a40db | |||
004a8b71a2 | |||
f0fce3086e | |||
a9e7db6fc0 | |||
2bd681d58f | |||
51fef3ce60 | |||
df9e7bf6ee | |||
04479bb70a | |||
cdc90411e5 | |||
dcb05a3093 | |||
b055cda64d | |||
f68602280e | |||
f9483afe95 | |||
d742534f6a | |||
99eb8eb8ed | |||
1dea024880 | |||
f6372249b4 | |||
ada04ed4fc | |||
795d9e4413 | |||
67f2e4671a | |||
214f837041 | |||
c48c9ae628 | |||
7003ad608a | |||
104edca6f1 | |||
75345d91c0 | |||
c54eafc128 | |||
757942aefc | |||
1d57629036 | |||
8061066e27 | |||
c993b1bbe3 | |||
2cbf33ac14 |
.envrc
.github/workflows
backend
package.jsonvitest.unit.config.ts
src
ee/services
group
identity-project-additional-privilege-v2
identity-project-additional-privilege
permission
project-user-additional-privilege
lib
server
plugins
routes
services
auth
group-project
identity-aws-auth
identity-azure-auth
identity-gcp-auth
identity-jwt-auth
identity-kubernetes-auth
identity-oidc-auth
identity-project
identity-token-auth
identity-ua
identity
project-membership
super-admin
user
cli
company/handbook
docs
flake.lockflake.nixfrontend/src
components
hooks/api
layouts/OrganizationLayout/components
pages
admin
OverviewPage
SignUpPage
auth
secret-manager/SecretDashboardPage/components
DynamicSecretListView/EditDynamicSecretForm
SecretListView
user/PersonalSettingsPage/components
3
.envrc
Normal file
3
.envrc
Normal file
@ -0,0 +1,3 @@
|
||||
# Learn more at https://direnv.net
|
||||
# We instruct direnv to use our Nix flake for a consistent development environment.
|
||||
use flake
|
@ -92,7 +92,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Install openapi-diff
|
||||
run: go install github.com/tufin/oasdiff@latest
|
||||
run: go install github.com/oasdiff/oasdiff@latest
|
||||
- name: Running OpenAPI Spec diff action
|
||||
run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR
|
||||
- name: cleanup
|
||||
|
8
.github/workflows/run-backend-tests.yml
vendored
8
.github/workflows/run-backend-tests.yml
vendored
@ -34,7 +34,10 @@ jobs:
|
||||
working-directory: backend
|
||||
- name: Start postgres and redis
|
||||
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
|
||||
- name: Start integration test
|
||||
- name: Run unit test
|
||||
run: npm run test:unit
|
||||
working-directory: backend
|
||||
- name: Run integration test
|
||||
run: npm run test:e2e
|
||||
working-directory: backend
|
||||
env:
|
||||
@ -44,4 +47,5 @@ jobs:
|
||||
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
|
||||
- name: cleanup
|
||||
run: |
|
||||
docker compose -f "docker-compose.dev.yml" down
|
||||
docker compose -f "docker-compose.dev.yml" down
|
||||
|
||||
|
@ -40,6 +40,7 @@
|
||||
"type:check": "tsc --noEmit",
|
||||
"lint:fix": "eslint --fix --ext js,ts ./src",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"test:unit": "vitest run -c vitest.unit.config.ts",
|
||||
"test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1",
|
||||
"test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1",
|
||||
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts",
|
||||
|
@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify";
|
||||
|
||||
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
|
||||
import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
|
||||
@ -87,9 +87,14 @@ export const groupServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
const isCustomRole = Boolean(customRole);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" });
|
||||
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to create a more privileged group",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const group = await groupDAL.transaction(async (tx) => {
|
||||
const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx);
|
||||
@ -156,9 +161,13 @@ export const groupServiceFactory = ({
|
||||
);
|
||||
|
||||
const isCustomRole = Boolean(customOrgRole);
|
||||
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredNewRolePermission)
|
||||
throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update a more privileged group",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
if (isCustomRole) customRole = customOrgRole;
|
||||
}
|
||||
|
||||
@ -329,9 +338,13 @@ export const groupServiceFactory = ({
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPrivileges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to add user to more privileged group",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||
@ -396,9 +409,13 @@ export const groupServiceFactory = ({
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPrivileges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to delete user from more privileged group",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||
|
@ -3,7 +3,7 @@ import { packRules } from "@casl/ability/extra";
|
||||
import ms from "ms";
|
||||
|
||||
import { ActionProjectType, TableName } from "@app/db/schemas";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
@ -79,9 +79,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@ -161,9 +165,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || []));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
if (data?.slug) {
|
||||
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
@ -239,9 +247,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.Any
|
||||
});
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
|
||||
return {
|
||||
|
@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
import ms from "ms";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
@ -88,9 +88,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@ -172,9 +176,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || []));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@ -268,9 +276,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.Any
|
||||
});
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to edit more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
|
@ -5,22 +5,6 @@ import { PermissionConditionOperators } from "@app/lib/casl";
|
||||
|
||||
export const PermissionConditionSchema = {
|
||||
[PermissionConditionOperators.$IN]: z.string().trim().min(1).array(),
|
||||
[PermissionConditionOperators.$ALL]: z.string().trim().min(1).array(),
|
||||
[PermissionConditionOperators.$REGEX]: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(el) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(el);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Invalid regex pattern" }
|
||||
),
|
||||
[PermissionConditionOperators.$EQ]: z.string().min(1),
|
||||
[PermissionConditionOperators.$NEQ]: z.string().min(1),
|
||||
[PermissionConditionOperators.$GLOB]: z
|
||||
|
@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
import ms from "ms";
|
||||
|
||||
import { ActionProjectType, TableName } from "@app/db/schemas";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
@ -76,9 +76,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetUserPermission.update(targetUserPermission.rules.concat(customPermission));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged user",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@ -163,9 +167,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
|
||||
// we need to validate that the privilege given is not higher than the assigning users permission
|
||||
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
|
||||
targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || []));
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
if (dto?.slug) {
|
||||
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
|
||||
|
669
backend/src/lib/casl/boundary.test.ts
Normal file
669
backend/src/lib/casl/boundary.test.ts
Normal file
@ -0,0 +1,669 @@
|
||||
import { createMongoAbility } from "@casl/ability";
|
||||
|
||||
import { PermissionConditionOperators } from ".";
|
||||
import { validatePermissionBoundary } from "./boundary";
|
||||
|
||||
describe("Validate Permission Boundary Function", () => {
|
||||
test.each([
|
||||
{
|
||||
title: "child with equal privilege",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
expectValid: true,
|
||||
missingPermissions: []
|
||||
},
|
||||
{
|
||||
title: "child with less privilege",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
expectValid: true,
|
||||
missingPermissions: []
|
||||
},
|
||||
{
|
||||
title: "child with more privilege",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
expectValid: false,
|
||||
missingPermissions: [{ action: "edit", subject: "secrets" }]
|
||||
},
|
||||
{
|
||||
title: "parent with multiple and child with multiple",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets"
|
||||
},
|
||||
{
|
||||
action: ["create", "edit"],
|
||||
subject: "members"
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "members"
|
||||
},
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets"
|
||||
}
|
||||
]),
|
||||
expectValid: true,
|
||||
missingPermissions: []
|
||||
},
|
||||
{
|
||||
title: "Child with no access",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets"
|
||||
},
|
||||
{
|
||||
action: ["create", "edit"],
|
||||
subject: "members"
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([]),
|
||||
expectValid: true,
|
||||
missingPermissions: []
|
||||
},
|
||||
{
|
||||
title: "Parent and child disjoint set",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
expectValid: false,
|
||||
missingPermissions: ["create", "edit", "delete", "read"].map((el) => ({
|
||||
action: el,
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}))
|
||||
},
|
||||
{
|
||||
title: "Parent with inverted rules",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "read",
|
||||
subject: "secrets",
|
||||
inverted: true,
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" },
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: "read",
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" },
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
expectValid: true,
|
||||
missingPermissions: []
|
||||
},
|
||||
{
|
||||
title: "Parent with inverted rules - child accessing invalid one",
|
||||
parentPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create", "edit", "delete", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "read",
|
||||
subject: "secrets",
|
||||
inverted: true,
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" },
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: "read",
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" },
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
|
||||
}
|
||||
}
|
||||
]),
|
||||
expectValid: false,
|
||||
missingPermissions: [
|
||||
{
|
||||
action: "read",
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" },
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])("Check permission: $title", ({ parentPermission, childPermission, expectValid, missingPermissions }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
if (expectValid) {
|
||||
expect(permissionBoundary.isValid).toBeTruthy();
|
||||
} else {
|
||||
expect(permissionBoundary.isValid).toBeFalsy();
|
||||
expect(permissionBoundary.missingPermissions).toEqual(expect.arrayContaining(missingPermissions));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validate Permission Boundary: Checking Parent $eq operator", () => {
|
||||
const parentPermission = createMongoAbility([
|
||||
{
|
||||
action: ["create", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$GLOB]: "dev" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator truthy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "prod" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$GLOB]: "dev**" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$NEQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$GLOB]: "staging" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator falsy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validate Permission Boundary: Checking Parent $neq operator", () => {
|
||||
const parentPermission = createMongoAbility([
|
||||
{
|
||||
action: ["create", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$NEQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/staging"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/dev**" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator truthy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/hello" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$NEQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$NEQ]: "/" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator falsy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validate Permission Boundary: Checking Parent $IN operator", () => {
|
||||
const parentPermission = createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "dev" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: `${PermissionConditionOperators.$IN} - 2`,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$GLOB]: "dev" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator truthy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$EQ]: "prod" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$NEQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$NEQ]: "dev" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["edit"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
environment: { [PermissionConditionOperators.$GLOB]: "dev**" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator falsy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validate Permission Boundary: Checking Parent $GLOB operator", () => {
|
||||
const parentPermission = createMongoAbility([
|
||||
{
|
||||
action: ["create", "read"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$IN]: ["/hello/world", "/hello/world2"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**/world" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator truthy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$EQ]: "/print" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$NEQ,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello/world" }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$IN,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] }
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
{
|
||||
operator: PermissionConditionOperators.$GLOB,
|
||||
childPermission: createMongoAbility([
|
||||
{
|
||||
action: ["create"],
|
||||
subject: "secrets",
|
||||
conditions: {
|
||||
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
])("Child $operator falsy cases", ({ childPermission }) => {
|
||||
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
|
||||
expect(permissionBoundary.isValid).toBeFalsy();
|
||||
});
|
||||
});
|
249
backend/src/lib/casl/boundary.ts
Normal file
249
backend/src/lib/casl/boundary.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import { MongoAbility } from "@casl/ability";
|
||||
import { MongoQuery } from "@ucast/mongo2js";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
import { PermissionConditionOperators } from "./index";
|
||||
|
||||
type TMissingPermission = {
|
||||
action: string;
|
||||
subject: string;
|
||||
conditions?: MongoQuery;
|
||||
};
|
||||
|
||||
type TPermissionConditionShape = {
|
||||
[PermissionConditionOperators.$EQ]: string;
|
||||
[PermissionConditionOperators.$NEQ]: string;
|
||||
[PermissionConditionOperators.$GLOB]: string;
|
||||
[PermissionConditionOperators.$IN]: string[];
|
||||
};
|
||||
|
||||
const getPermissionSetID = (action: string, subject: string) => `${action}:${subject}`;
|
||||
const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation);
|
||||
const formatConditionOperator = (condition: TPermissionConditionShape | string) => {
|
||||
return (
|
||||
typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition
|
||||
) as TPermissionConditionShape;
|
||||
};
|
||||
|
||||
const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => {
|
||||
// we compute each operator against each other in left hand side and right hand side
|
||||
if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) {
|
||||
const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ];
|
||||
const isInverted = !subset[PermissionConditionOperators.$EQ];
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$EQ] &&
|
||||
invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$NEQ] &&
|
||||
invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$IN] &&
|
||||
invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// ne and glob cannot match each other
|
||||
if (parentSet[PermissionConditionOperators.$GLOB] && isInverted) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$GLOB] &&
|
||||
!picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (subset[PermissionConditionOperators.$IN]) {
|
||||
const subsetOperatorValue = subset[PermissionConditionOperators.$IN];
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$EQ] &&
|
||||
(subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$NEQ] &&
|
||||
subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$IN] &&
|
||||
!subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$GLOB] &&
|
||||
!subsetOperatorValue.every((el) =>
|
||||
picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], {
|
||||
strictSlashes: false
|
||||
})
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (subset[PermissionConditionOperators.$GLOB]) {
|
||||
const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB];
|
||||
const { isGlob } = picomatch.scan(subsetOperatorValue);
|
||||
// if it's glob, all other fixed operators would make this superset because glob is powerful. like eq
|
||||
// example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger
|
||||
if (
|
||||
isGlob &&
|
||||
Object.keys(parentSet).some(
|
||||
(el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$EQ] &&
|
||||
parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$NEQ] &&
|
||||
picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, {
|
||||
strictSlashes: false
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// if parent set is IN, glob cannot be used for children - It's a bigger scope
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$IN] &&
|
||||
!parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
parentSet[PermissionConditionOperators.$GLOB] &&
|
||||
!picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], {
|
||||
strictSlashes: false
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isSubsetForSamePermissionSubjectAction = (
|
||||
parentSetRules: ReturnType<MongoAbility["possibleRulesFor"]>,
|
||||
subsetRules: ReturnType<MongoAbility["possibleRulesFor"]>,
|
||||
appendToMissingPermission: (condition?: MongoQuery) => void
|
||||
) => {
|
||||
const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions);
|
||||
if (isMissingConditionInParent) return true;
|
||||
|
||||
// all subset rules must pass in comparison to parent rul
|
||||
return subsetRules.every((subsetRule) => {
|
||||
const subsetRuleConditions = subsetRule.conditions as Record<string, TPermissionConditionShape | string>;
|
||||
// compare subset rule with all parent rules
|
||||
const isSubsetOfNonInvertedParentSet = parentSetRules
|
||||
.filter((el) => !el.inverted)
|
||||
.some((parentSetRule) => {
|
||||
// get conditions and iterate
|
||||
const parentSetRuleConditions = parentSetRule?.conditions as Record<string, TPermissionConditionShape | string>;
|
||||
if (!parentSetRuleConditions) return true;
|
||||
return Object.keys(parentSetRuleConditions).every((parentConditionField) => {
|
||||
// if parent condition is missing then it's never a subset
|
||||
if (!subsetRuleConditions?.[parentConditionField]) return false;
|
||||
|
||||
// standardize the conditions plain string operator => $eq function
|
||||
const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]);
|
||||
const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField];
|
||||
const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition);
|
||||
return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators);
|
||||
});
|
||||
});
|
||||
|
||||
const invertedParentSetRules = parentSetRules.filter((el) => el.inverted);
|
||||
const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length
|
||||
? !invertedParentSetRules.some((parentSetRule) => {
|
||||
// get conditions and iterate
|
||||
const parentSetRuleConditions = parentSetRule?.conditions as Record<
|
||||
string,
|
||||
TPermissionConditionShape | string
|
||||
>;
|
||||
if (!parentSetRuleConditions) return true;
|
||||
return Object.keys(parentSetRuleConditions).every((parentConditionField) => {
|
||||
// if parent condition is missing then it's never a subset
|
||||
if (!subsetRuleConditions?.[parentConditionField]) return false;
|
||||
|
||||
// standardize the conditions plain string operator => $eq function
|
||||
const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]);
|
||||
const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField];
|
||||
const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition);
|
||||
return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators);
|
||||
});
|
||||
})
|
||||
: true;
|
||||
const isSubset = isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet;
|
||||
if (!isSubset) {
|
||||
appendToMissingPermission(subsetRule.conditions);
|
||||
}
|
||||
return isSubset;
|
||||
});
|
||||
};
|
||||
|
||||
export const validatePermissionBoundary = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => {
|
||||
const checkedPermissionRules = new Set<string>();
|
||||
const missingPermissions: TMissingPermission[] = [];
|
||||
|
||||
subsetPermissions.rules.forEach((subsetPermissionRules) => {
|
||||
const subsetPermissionSubject = subsetPermissionRules.subject.toString();
|
||||
let subsetPermissionActions: string[] = [];
|
||||
|
||||
// actions can be string or string[]
|
||||
if (typeof subsetPermissionRules.action === "string") {
|
||||
subsetPermissionActions.push(subsetPermissionRules.action);
|
||||
} else {
|
||||
subsetPermissionRules.action.forEach((subsetPermissionAction) => {
|
||||
subsetPermissionActions.push(subsetPermissionAction);
|
||||
});
|
||||
}
|
||||
|
||||
// if action is already processed ignore
|
||||
subsetPermissionActions = subsetPermissionActions.filter(
|
||||
(el) => !checkedPermissionRules.has(getPermissionSetID(el, subsetPermissionSubject))
|
||||
);
|
||||
|
||||
if (!subsetPermissionActions.length) return;
|
||||
subsetPermissionActions.forEach((subsetPermissionAction) => {
|
||||
const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor(
|
||||
subsetPermissionAction,
|
||||
subsetPermissionSubject
|
||||
);
|
||||
const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted);
|
||||
if (!nonInveretedOnes.length) {
|
||||
missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject });
|
||||
return;
|
||||
}
|
||||
|
||||
const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject);
|
||||
isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules, (conditions) => {
|
||||
missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject, conditions });
|
||||
});
|
||||
});
|
||||
|
||||
subsetPermissionActions.forEach((el) =>
|
||||
checkedPermissionRules.add(getPermissionSetID(el, subsetPermissionSubject))
|
||||
);
|
||||
});
|
||||
|
||||
if (missingPermissions.length) {
|
||||
return { isValid: false as const, missingPermissions };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { buildMongoQueryMatcher, MongoAbility } from "@casl/ability";
|
||||
import { buildMongoQueryMatcher } from "@casl/ability";
|
||||
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
@ -20,45 +20,8 @@ const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
|
||||
|
||||
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
|
||||
|
||||
/**
|
||||
* Extracts and formats permissions from a CASL Ability object or a raw permission set.
|
||||
*/
|
||||
const extractPermissions = (ability: MongoAbility) => {
|
||||
const permissions: string[] = [];
|
||||
ability.rules.forEach((permission) => {
|
||||
if (typeof permission.action === "string") {
|
||||
permissions.push(`${permission.action}_${permission.subject as string}`);
|
||||
} else {
|
||||
permission.action.forEach((permissionAction) => {
|
||||
permissions.push(`${permissionAction}_${permission.subject as string}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
return permissions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares two sets of permissions to determine if the first set is at least as privileged as the second set.
|
||||
* The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions.
|
||||
*
|
||||
*/
|
||||
export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2: MongoAbility) => {
|
||||
const set1 = new Set(extractPermissions(permissions1));
|
||||
const set2 = new Set(extractPermissions(permissions2));
|
||||
|
||||
for (const perm of set2) {
|
||||
if (!set1.has(perm)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return set1.size >= set2.size;
|
||||
};
|
||||
|
||||
export enum PermissionConditionOperators {
|
||||
$IN = "$in",
|
||||
$ALL = "$all",
|
||||
$REGEX = "$regex",
|
||||
$EQ = "$eq",
|
||||
$NEQ = "$ne",
|
||||
$GLOB = "$glob"
|
||||
|
@ -52,10 +52,18 @@ export class ForbiddenRequestError extends Error {
|
||||
|
||||
error: unknown;
|
||||
|
||||
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) {
|
||||
details?: unknown;
|
||||
|
||||
constructor({
|
||||
name,
|
||||
error,
|
||||
message,
|
||||
details
|
||||
}: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) {
|
||||
super(message ?? "You are not allowed to access this resource");
|
||||
this.name = name || "ForbiddenError";
|
||||
this.error = error;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -96,6 +96,7 @@ export const pingGatewayAndVerify = async ({
|
||||
error: err as Error
|
||||
});
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
const stream = quicClient.connection.newStream("bidi");
|
||||
@ -108,17 +109,13 @@ export const pingGatewayAndVerify = async ({
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
throw new BadRequestError({
|
||||
message: "Gateway closed before receiving PONG"
|
||||
});
|
||||
throw new Error("Gateway closed before receiving PONG");
|
||||
}
|
||||
|
||||
const response = Buffer.from(value).toString();
|
||||
|
||||
if (response !== "PONG\n" && response !== "PONG") {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to Ping. Unexpected response: ${response}`
|
||||
});
|
||||
throw new Error(`Failed to Ping. Unexpected response: ${response}`);
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
@ -146,6 +143,7 @@ interface TProxyServer {
|
||||
server: net.Server;
|
||||
port: number;
|
||||
cleanup: () => Promise<void>;
|
||||
getProxyError: () => string;
|
||||
}
|
||||
|
||||
const setupProxyServer = async ({
|
||||
@ -170,6 +168,7 @@ const setupProxyServer = async ({
|
||||
error: err as Error
|
||||
});
|
||||
});
|
||||
const proxyErrorMsg = [""];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@ -185,31 +184,33 @@ const setupProxyServer = async ({
|
||||
const forwardWriter = stream.writable.getWriter();
|
||||
await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`));
|
||||
forwardWriter.releaseLock();
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
|
||||
// Set up bidirectional copy
|
||||
const setupCopy = async () => {
|
||||
const setupCopy = () => {
|
||||
// Client to QUIC
|
||||
// eslint-disable-next-line
|
||||
(async () => {
|
||||
try {
|
||||
const writer = stream.writable.getWriter();
|
||||
const writer = stream.writable.getWriter();
|
||||
|
||||
// Create a handler for client data
|
||||
clientConn.on("data", async (chunk) => {
|
||||
await writer.write(chunk);
|
||||
// Create a handler for client data
|
||||
clientConn.on("data", (chunk) => {
|
||||
writer.write(chunk).catch((err) => {
|
||||
proxyErrorMsg.push((err as Error)?.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle client connection close
|
||||
clientConn.on("end", async () => {
|
||||
await writer.close();
|
||||
// Handle client connection close
|
||||
clientConn.on("end", () => {
|
||||
writer.close().catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
clientConn.on("error", async (err) => {
|
||||
await writer.abort(err);
|
||||
clientConn.on("error", (clientConnErr) => {
|
||||
writer.abort(clientConnErr?.message).catch((err) => {
|
||||
proxyErrorMsg.push((err as Error)?.message);
|
||||
});
|
||||
} catch (err) {
|
||||
clientConn.destroy();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// QUIC to Client
|
||||
@ -238,15 +239,18 @@ const setupProxyServer = async ({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
proxyErrorMsg.push((err as Error)?.message);
|
||||
clientConn.destroy();
|
||||
}
|
||||
})();
|
||||
};
|
||||
await setupCopy();
|
||||
//
|
||||
|
||||
setupCopy();
|
||||
// Handle connection closure
|
||||
clientConn.on("close", async () => {
|
||||
await stream.destroy();
|
||||
clientConn.on("close", () => {
|
||||
stream.destroy().catch((err) => {
|
||||
proxyErrorMsg.push((err as Error)?.message);
|
||||
});
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
@ -254,13 +258,18 @@ const setupProxyServer = async ({
|
||||
await stream.destroy();
|
||||
};
|
||||
|
||||
clientConn.on("error", (err) => {
|
||||
logger.error(err, "Client socket error");
|
||||
void cleanup();
|
||||
reject(err);
|
||||
clientConn.on("error", (clientConnErr) => {
|
||||
logger.error(clientConnErr, "Client socket error");
|
||||
cleanup().catch((err) => {
|
||||
logger.error(err, "Client conn cleanup");
|
||||
});
|
||||
});
|
||||
|
||||
clientConn.on("end", cleanup);
|
||||
clientConn.on("end", () => {
|
||||
cleanup().catch((err) => {
|
||||
logger.error(err, "Client conn end");
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to establish target connection:");
|
||||
clientConn.end();
|
||||
@ -272,12 +281,12 @@ const setupProxyServer = async ({
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.on("close", async () => {
|
||||
await quicClient?.destroy();
|
||||
server.on("close", () => {
|
||||
quicClient?.destroy().catch((err) => {
|
||||
logger.error(err, "Failed to destroy quic client");
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-enable */
|
||||
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
@ -293,7 +302,8 @@ const setupProxyServer = async ({
|
||||
cleanup: async () => {
|
||||
server.close();
|
||||
await quicClient?.destroy();
|
||||
}
|
||||
},
|
||||
getProxyError: () => proxyErrorMsg.join(",")
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -316,7 +326,7 @@ export const withGatewayProxy = async (
|
||||
const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options;
|
||||
|
||||
// Setup the proxy server
|
||||
const { port, cleanup } = await setupProxyServer({
|
||||
const { port, cleanup, getProxyError } = await setupProxyServer({
|
||||
targetHost,
|
||||
targetPort,
|
||||
relayPort,
|
||||
@ -330,8 +340,12 @@ export const withGatewayProxy = async (
|
||||
// Execute the callback with the allocated port
|
||||
await callback(port);
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to proxy");
|
||||
throw new BadRequestError({ message: (err as Error)?.message });
|
||||
const proxyErrorMessage = getProxyError();
|
||||
if (proxyErrorMessage) {
|
||||
logger.error(new Error(proxyErrorMessage), "Failed to proxy");
|
||||
}
|
||||
logger.error(err, "Failed to do gateway");
|
||||
throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message });
|
||||
} finally {
|
||||
// Ensure cleanup happens regardless of success or failure
|
||||
await cleanup();
|
||||
|
@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const TURN_TOKEN_TTL = 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
const TURN_TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
export const getTurnCredentials = (id: string, authSecret: string, ttl = TURN_TOKEN_TTL) => {
|
||||
const timestamp = Math.floor((Date.now() + ttl) / 1000);
|
||||
const username = `${timestamp}:${id}`;
|
||||
|
@ -122,7 +122,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.Forbidden,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
error: error.name,
|
||||
details: error?.details
|
||||
});
|
||||
} else if (error instanceof RateLimitError) {
|
||||
void res.status(HttpStatusCodes.TooManyRequests).send({
|
||||
|
@ -635,6 +635,7 @@ export const registerRoutes = async (
|
||||
});
|
||||
const superAdminService = superAdminServiceFactory({
|
||||
userDAL,
|
||||
identityDAL,
|
||||
userAliasDAL,
|
||||
authService: loginService,
|
||||
serverCfgDAL: superAdminDAL,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
@ -118,7 +118,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
querystring: z.object({
|
||||
searchTerm: z.string().default(""),
|
||||
offset: z.coerce.number().default(0),
|
||||
limit: z.coerce.number().max(100).default(20)
|
||||
limit: z.coerce.number().max(100).default(20),
|
||||
// TODO: remove this once z.coerce.boolean() is supported
|
||||
adminsOnly: z
|
||||
.string()
|
||||
.transform((val) => val === "true")
|
||||
.default("false")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -149,6 +154,43 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/identity-management/identities",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
searchTerm: z.string().default(""),
|
||||
offset: z.coerce.number().default(0),
|
||||
limit: z.coerce.number().max(100).default(20)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identities: IdentitiesSchema.pick({
|
||||
name: true,
|
||||
id: true
|
||||
}).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: (req, res, done) => {
|
||||
verifyAuth([AuthMode.JWT])(req, res, () => {
|
||||
verifySuperAdmin(req, res, done);
|
||||
});
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identities = await server.services.superAdmin.getIdentities({
|
||||
...req.query
|
||||
});
|
||||
|
||||
return {
|
||||
identities
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/integrations/slack/config",
|
||||
|
@ -91,7 +91,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await projectRouter.register(registerProjectMembershipRouter);
|
||||
await projectRouter.register(registerSecretTagRouter);
|
||||
},
|
||||
|
||||
{ prefix: "/workspace" }
|
||||
);
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { validateSignUpAuthorization } from "@app/services/auth/auth-fns";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { UserEncryption } from "@app/services/user/user-types";
|
||||
|
||||
export const registerPasswordRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@ -113,20 +114,16 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string(),
|
||||
user: UsersSchema,
|
||||
token: z.string()
|
||||
token: z.string(),
|
||||
userEncryptionVersion: z.nativeEnum(UserEncryption)
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { token, user } = await server.services.password.verifyPasswordResetEmail(req.body.email, req.body.code);
|
||||
const passwordReset = await server.services.password.verifyPasswordResetEmail(req.body.email, req.body.code);
|
||||
|
||||
return {
|
||||
message: "Successfully verified email",
|
||||
user,
|
||||
token
|
||||
};
|
||||
return passwordReset;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router";
|
||||
import { registerIdentityProjectRouter } from "./identity-project-router";
|
||||
import { registerMfaRouter } from "./mfa-router";
|
||||
import { registerOrgRouter } from "./organization-router";
|
||||
import { registerPasswordRouter } from "./password-router";
|
||||
import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerServiceTokenRouter } from "./service-token-router";
|
||||
@ -12,6 +13,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerMfaRouter, { prefix: "/auth" });
|
||||
await server.register(registerUserRouter, { prefix: "/users" });
|
||||
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
|
||||
await server.register(registerPasswordRouter, { prefix: "/password" });
|
||||
await server.register(
|
||||
async (orgRouter) => {
|
||||
await orgRouter.register(registerOrgRouter);
|
||||
|
53
backend/src/server/routes/v2/password-router.ts
Normal file
53
backend/src/server/routes/v2/password-router.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type";
|
||||
|
||||
export const registerPasswordRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/password-reset",
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
newPassword: z.string().trim()
|
||||
})
|
||||
},
|
||||
handler: async (req) => {
|
||||
const token = validatePasswordResetAuthorization(req.headers.authorization);
|
||||
await server.services.password.resetPasswordV2({
|
||||
type: ResetPasswordV2Type.Recovery,
|
||||
newPassword: req.body.newPassword,
|
||||
userId: token.userId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/user/password-reset",
|
||||
schema: {
|
||||
body: z.object({
|
||||
oldPassword: z.string().trim(),
|
||||
newPassword: z.string().trim()
|
||||
})
|
||||
},
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: async (req) => {
|
||||
await server.services.password.resetPasswordV2({
|
||||
type: ResetPasswordV2Type.LoggedInReset,
|
||||
userId: req.permission.id,
|
||||
newPassword: req.body.newPassword,
|
||||
oldPassword: req.body.oldPassword
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
@ -45,6 +45,36 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid
|
||||
if (decodedToken.userId !== userId) throw new UnauthorizedError();
|
||||
};
|
||||
|
||||
export const validatePasswordResetAuthorization = (token?: string) => {
|
||||
if (!token) throw new UnauthorizedError();
|
||||
|
||||
const appCfg = getConfig();
|
||||
const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>token?.split(" ", 2) ?? [null, null];
|
||||
if (AUTH_TOKEN_TYPE === null) {
|
||||
throw new UnauthorizedError({ message: "Missing Authorization Header in the request header." });
|
||||
}
|
||||
if (AUTH_TOKEN_TYPE.toLowerCase() !== "bearer") {
|
||||
throw new UnauthorizedError({
|
||||
message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.`
|
||||
});
|
||||
}
|
||||
if (AUTH_TOKEN_VALUE === null) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Missing Authorization Body in the request header"
|
||||
});
|
||||
}
|
||||
|
||||
const decodedToken = jwt.verify(AUTH_TOKEN_VALUE, appCfg.AUTH_SECRET) as AuthModeProviderSignUpTokenPayload;
|
||||
|
||||
if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) {
|
||||
throw new UnauthorizedError({
|
||||
message: `The provided authentication token type is not supported.`
|
||||
});
|
||||
}
|
||||
|
||||
return decodedToken;
|
||||
};
|
||||
|
||||
export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => {
|
||||
if (isLocked) {
|
||||
throw new ForbiddenRequestError({
|
||||
|
@ -4,6 +4,8 @@ import jwt from "jsonwebtoken";
|
||||
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { generateUserSrpKeys } from "@app/lib/crypto/srp";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
@ -12,14 +14,18 @@ import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TTotpConfigDALFactory } from "../totp/totp-config-dal";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { UserEncryption } from "../user/user-types";
|
||||
import { TAuthDALFactory } from "./auth-dal";
|
||||
import {
|
||||
ResetPasswordV2Type,
|
||||
TChangePasswordDTO,
|
||||
TCreateBackupPrivateKeyDTO,
|
||||
TResetPasswordV2DTO,
|
||||
TResetPasswordViaBackupKeyDTO,
|
||||
TSetupPasswordViaBackupKeyDTO
|
||||
} from "./auth-password-type";
|
||||
import { ActorType, AuthMethod, AuthTokenType } from "./auth-type";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
type TAuthPasswordServiceFactoryDep = {
|
||||
authDAL: TAuthDALFactory;
|
||||
@ -114,26 +120,31 @@ export const authPaswordServiceFactory = ({
|
||||
* Email password reset flow via email. Step 1 send email
|
||||
*/
|
||||
const sendPasswordResetEmail = async (email: string) => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
// ignore as user is not found to avoid an outside entity to identify infisical registered accounts
|
||||
if (!user || (user && !user.isAccepted)) return;
|
||||
const sendEmail = async () => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
|
||||
const cfg = getConfig();
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_PASSWORD_RESET,
|
||||
userId: user.id
|
||||
});
|
||||
if (user && user.isAccepted) {
|
||||
const cfg = getConfig();
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_PASSWORD_RESET,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.ResetPassword,
|
||||
recipients: [email],
|
||||
subjectLine: "Infisical password reset",
|
||||
substitutions: {
|
||||
email,
|
||||
token,
|
||||
callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : ""
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.ResetPassword,
|
||||
recipients: [email],
|
||||
subjectLine: "Infisical password reset",
|
||||
substitutions: {
|
||||
email,
|
||||
token,
|
||||
callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : ""
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// note(daniel): run in background to prevent timing attacks
|
||||
void sendEmail().catch((err) => logger.error(err, "Failed to send password reset email"));
|
||||
};
|
||||
|
||||
/*
|
||||
@ -142,6 +153,11 @@ export const authPaswordServiceFactory = ({
|
||||
const verifyPasswordResetEmail = async (email: string, code: string) => {
|
||||
const cfg = getConfig();
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
|
||||
const userEnc = await userDAL.findUserEncKeyByUserId(user.id);
|
||||
|
||||
if (!userEnc) throw new BadRequestError({ message: "Failed to find user encryption data" });
|
||||
|
||||
// ignore as user is not found to avoid an outside entity to identify infisical registered accounts
|
||||
if (!user || (user && !user.isAccepted)) {
|
||||
throw new Error("Failed email verification for pass reset");
|
||||
@ -162,8 +178,91 @@ export const authPaswordServiceFactory = ({
|
||||
{ expiresIn: cfg.JWT_SIGNUP_LIFETIME }
|
||||
);
|
||||
|
||||
return { token, user };
|
||||
return { token, user, userEncryptionVersion: userEnc.encryptionVersion as UserEncryption };
|
||||
};
|
||||
|
||||
const resetPasswordV2 = async ({ userId, newPassword, type, oldPassword }: TResetPasswordV2DTO) => {
|
||||
const cfg = getConfig();
|
||||
|
||||
const user = await userDAL.findUserEncKeyByUserId(userId);
|
||||
if (!user) {
|
||||
throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` });
|
||||
}
|
||||
|
||||
if (!user.hashedPassword) {
|
||||
throw new BadRequestError({ message: "Unable to reset password, no password is set" });
|
||||
}
|
||||
|
||||
if (!user.authMethods?.includes(AuthMethod.EMAIL)) {
|
||||
throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" });
|
||||
}
|
||||
|
||||
// we check the old password if the user is resetting their password while logged in
|
||||
if (type === ResetPasswordV2Type.LoggedInReset) {
|
||||
if (!oldPassword) {
|
||||
throw new BadRequestError({ message: "Current password is required." });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, user.hashedPassword);
|
||||
if (!isValid) {
|
||||
throw new BadRequestError({ message: "Incorrect current password." });
|
||||
}
|
||||
}
|
||||
|
||||
const newHashedPassword = await bcrypt.hash(newPassword, cfg.BCRYPT_SALT_ROUND);
|
||||
|
||||
// we need to get the original private key first for v2
|
||||
let privateKey: string;
|
||||
if (
|
||||
user.serverEncryptedPrivateKey &&
|
||||
user.serverEncryptedPrivateKeyTag &&
|
||||
user.serverEncryptedPrivateKeyIV &&
|
||||
user.serverEncryptedPrivateKeyEncoding &&
|
||||
user.encryptionVersion === UserEncryption.V2
|
||||
) {
|
||||
privateKey = infisicalSymmetricDecrypt({
|
||||
iv: user.serverEncryptedPrivateKeyIV,
|
||||
tag: user.serverEncryptedPrivateKeyTag,
|
||||
ciphertext: user.serverEncryptedPrivateKey,
|
||||
keyEncoding: user.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding
|
||||
});
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: "Cannot reset password without current credentials or recovery method",
|
||||
name: "Reset password"
|
||||
});
|
||||
}
|
||||
|
||||
const encKeys = await generateUserSrpKeys(user.username, newPassword, {
|
||||
publicKey: user.publicKey,
|
||||
privateKey
|
||||
});
|
||||
|
||||
const { tag, iv, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey);
|
||||
|
||||
await userDAL.updateUserEncryptionByUserId(userId, {
|
||||
hashedPassword: newHashedPassword,
|
||||
|
||||
// srp params
|
||||
salt: encKeys.salt,
|
||||
verifier: encKeys.verifier,
|
||||
|
||||
protectedKey: encKeys.protectedKey,
|
||||
protectedKeyIV: encKeys.protectedKeyIV,
|
||||
protectedKeyTag: encKeys.protectedKeyTag,
|
||||
encryptedPrivateKey: encKeys.encryptedPrivateKey,
|
||||
iv: encKeys.encryptedPrivateKeyIV,
|
||||
tag: encKeys.encryptedPrivateKeyTag,
|
||||
|
||||
serverEncryptedPrivateKey: ciphertext,
|
||||
serverEncryptedPrivateKeyIV: iv,
|
||||
serverEncryptedPrivateKeyTag: tag,
|
||||
serverEncryptedPrivateKeyEncoding: encoding
|
||||
});
|
||||
|
||||
await tokenService.revokeAllMySessions(userId);
|
||||
};
|
||||
|
||||
/*
|
||||
* Reset password of a user via backup key
|
||||
* */
|
||||
@ -391,6 +490,7 @@ export const authPaswordServiceFactory = ({
|
||||
createBackupPrivateKey,
|
||||
getBackupPrivateKeyOfUser,
|
||||
sendPasswordSetupEmail,
|
||||
setupPassword
|
||||
setupPassword,
|
||||
resetPasswordV2
|
||||
};
|
||||
};
|
||||
|
@ -13,6 +13,18 @@ export type TChangePasswordDTO = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export enum ResetPasswordV2Type {
|
||||
Recovery = "recovery",
|
||||
LoggedInReset = "logged-in-reset"
|
||||
}
|
||||
|
||||
export type TResetPasswordV2DTO = {
|
||||
type: ResetPasswordV2Type;
|
||||
userId: string;
|
||||
newPassword: string;
|
||||
oldPassword?: string;
|
||||
};
|
||||
|
||||
export type TResetPasswordViaBackupKeyDTO = {
|
||||
userId: string;
|
||||
protectedKey: string;
|
||||
|
@ -4,7 +4,7 @@ import ms from "ms";
|
||||
import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
@ -102,11 +102,13 @@ export const groupProjectServiceFactory = ({
|
||||
project.id
|
||||
);
|
||||
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" });
|
||||
}
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to assign group to a more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
@ -267,12 +269,13 @@ export const groupProjectServiceFactory = ({
|
||||
requestedRoleChange,
|
||||
project.id
|
||||
);
|
||||
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" });
|
||||
}
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to assign group to a more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
|
@ -7,7 +7,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -339,9 +339,12 @@ export const identityAwsAuthServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke aws auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke aws auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => {
|
||||
|
@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -312,9 +312,12 @@ export const identityAzureAuthServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke azure auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke azure auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => {
|
||||
|
@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -358,9 +358,12 @@ export const identityGcpAuthServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke gcp auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke gcp auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => {
|
||||
|
@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -508,11 +508,13 @@ export const identityJwtAuthServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission)) {
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke JWT auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke jwt auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => {
|
||||
const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx);
|
||||
|
@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/sche
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -487,9 +487,12 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke kubernetes auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke kubernetes auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => {
|
||||
|
@ -8,7 +8,7 @@ import { IdentityAuthMethod, TIdentityOidcAuthsUpdate } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -428,11 +428,13 @@ export const identityOidcAuthServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission)) {
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke OIDC auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke oidc auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => {
|
||||
const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx);
|
||||
|
@ -4,7 +4,7 @@ import ms from "ms";
|
||||
import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
|
||||
@ -91,11 +91,13 @@ export const identityProjectServiceFactory = ({
|
||||
projectId
|
||||
);
|
||||
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPriviledges) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" });
|
||||
}
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to assign to a more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
@ -185,9 +187,13 @@ export const identityProjectServiceFactory = ({
|
||||
projectId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission)) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" });
|
||||
}
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to change to a more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
@ -277,8 +283,13 @@ export const identityProjectServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.Any
|
||||
});
|
||||
if (!isAtLeastAsPrivileged(permission, identityRolePermission))
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to remove more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId });
|
||||
return deletedIdentity;
|
||||
|
@ -5,7 +5,7 @@ import { IdentityAuthMethod, TableName } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
@ -245,11 +245,13 @@ export const identityTokenAuthServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission)) {
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke Token Auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke token auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
|
||||
const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => {
|
||||
const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx);
|
||||
@ -295,10 +297,12 @@ export const identityTokenAuthServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to create token for identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to create token for identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId });
|
||||
@ -415,10 +419,12 @@ export const identityTokenAuthServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to update token for identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update token for identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const [token] = await identityAccessTokenDAL.update(
|
||||
|
@ -8,7 +8,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
|
||||
@ -367,9 +367,12 @@ export const identityUaServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke universal auth of identity with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke universal auth of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => {
|
||||
@ -414,10 +417,12 @@ export const identityUaServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to create client secret for a more privileged identity.",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
@ -475,9 +480,12 @@ export const identityUaServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to get identity client secret with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const identityUniversalAuth = await identityUaDAL.findOne({
|
||||
@ -524,9 +532,12 @@ export const identityUaServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to read identity client secret of project with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to read identity client secret of identity with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId);
|
||||
@ -566,10 +577,12 @@ export const identityUaServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission))
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke identity client secret with more privileged role"
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to revoke identity client secret with more privileged role",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, {
|
||||
|
@ -1,10 +1,42 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
import { TableName, TIdentities } from "@app/db/schemas";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
|
||||
export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
|
||||
|
||||
export const identityDALFactory = (db: TDbClient) => {
|
||||
const identityOrm = ormify(db, TableName.Identity);
|
||||
return identityOrm;
|
||||
|
||||
const getIdentitiesByFilter = async ({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm: string;
|
||||
sortBy?: keyof TIdentities;
|
||||
}) => {
|
||||
try {
|
||||
let query = db.replicaNode()(TableName.Identity);
|
||||
|
||||
if (searchTerm) {
|
||||
query = query.where((qb) => {
|
||||
void qb.whereILike("name", `%${searchTerm}%`);
|
||||
});
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
query = query.orderBy(sortBy);
|
||||
}
|
||||
|
||||
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity));
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get identities by filter" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...identityOrm, getIdentitiesByFilter };
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
|
||||
|
||||
@ -58,9 +58,13 @@ export const identityServiceFactory = ({
|
||||
orgId
|
||||
);
|
||||
const isCustomRole = Boolean(customRole);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to create a more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
|
||||
@ -129,9 +133,13 @@ export const identityServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to update a more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
let customRole: TOrgRoles | undefined;
|
||||
if (role) {
|
||||
@ -141,9 +149,13 @@ export const identityServiceFactory = ({
|
||||
);
|
||||
|
||||
const isCustomRole = Boolean(customOrgRole);
|
||||
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredNewRolePermission)
|
||||
throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" });
|
||||
const appliedRolePermissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!appliedRolePermissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to create a more privileged identity",
|
||||
details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions }
|
||||
});
|
||||
if (isCustomRole) customRole = customOrgRole;
|
||||
}
|
||||
|
||||
@ -216,9 +228,13 @@ export const identityServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
name: "PermissionBoundaryError",
|
||||
message: "Failed to delete more privileged identity",
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const deletedIdentity = await identityDAL.deleteById(id);
|
||||
|
||||
|
@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
@ -274,13 +274,13 @@ export const projectMembershipServiceFactory = ({
|
||||
projectId
|
||||
);
|
||||
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPriviledges) {
|
||||
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
|
||||
if (!permissionBoundary.isValid)
|
||||
throw new ForbiddenRequestError({
|
||||
message: `Failed to change to a more privileged role ${requestedRoleChange}`
|
||||
name: "PermissionBoundaryError",
|
||||
message: `Failed to change to a more privileged role ${requestedRoleChange}`,
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
|
@ -19,9 +19,11 @@ 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, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types";
|
||||
import { LoginMethod, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types";
|
||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||
|
||||
type TSuperAdminServiceFactoryDep = {
|
||||
identityDAL: Pick<TIdentityDALFactory, "getIdentitiesByFilter">;
|
||||
serverCfgDAL: TSuperAdminDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne">;
|
||||
@ -51,6 +53,7 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
export const superAdminServiceFactory = ({
|
||||
serverCfgDAL,
|
||||
userDAL,
|
||||
identityDAL,
|
||||
userAliasDAL,
|
||||
authService,
|
||||
orgService,
|
||||
@ -271,12 +274,13 @@ export const superAdminServiceFactory = ({
|
||||
return { token, user: userInfo, organization };
|
||||
};
|
||||
|
||||
const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => {
|
||||
const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => {
|
||||
return userDAL.getUsersByFilter({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy: "username"
|
||||
sortBy: "username",
|
||||
adminsOnly
|
||||
});
|
||||
};
|
||||
|
||||
@ -291,6 +295,15 @@ export const superAdminServiceFactory = ({
|
||||
return user;
|
||||
};
|
||||
|
||||
const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
|
||||
return identityDAL.getIdentitiesByFilter({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy: "name"
|
||||
});
|
||||
};
|
||||
|
||||
const grantServerAdminAccessToUser = async (userId: string) => {
|
||||
if (!licenseService.onPremFeatures?.instanceUserManagement) {
|
||||
throw new BadRequestError({
|
||||
@ -388,6 +401,7 @@ export const superAdminServiceFactory = ({
|
||||
adminSignUp,
|
||||
getUsers,
|
||||
deleteUser,
|
||||
getIdentities,
|
||||
getAdminSlackConfig,
|
||||
updateRootEncryptionStrategy,
|
||||
getConfiguredEncryptionStrategies,
|
||||
|
@ -20,6 +20,13 @@ export type TAdminGetUsersDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
export type TAdminGetIdentitiesDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export enum LoginMethod {
|
||||
|
@ -23,15 +23,18 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy
|
||||
sortBy,
|
||||
adminsOnly
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm: string;
|
||||
sortBy?: keyof TUsers;
|
||||
adminsOnly: boolean;
|
||||
}) => {
|
||||
try {
|
||||
let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false);
|
||||
|
||||
if (searchTerm) {
|
||||
query = query.where((qb) => {
|
||||
void qb
|
||||
@ -42,6 +45,10 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (adminsOnly) {
|
||||
query = query.where("superAdmin", true);
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
query = query.orderBy(sortBy);
|
||||
}
|
||||
|
17
backend/vitest.unit.config.ts
Normal file
17
backend/vitest.unit.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
env: {
|
||||
NODE_ENV: "test"
|
||||
},
|
||||
include: ["./src/**/*.test.ts"]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@app": path.resolve(__dirname, "./src")
|
||||
}
|
||||
}
|
||||
});
|
10
cli/go.mod
10
cli/go.mod
@ -29,9 +29,9 @@ require (
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/sys v0.30.0
|
||||
golang.org/x/term v0.29.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.org/x/term v0.30.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@ -115,8 +115,8 @@ require (
|
||||
golang.org/x/mod v0.23.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
google.golang.org/api v0.188.0 // indirect
|
||||
|
10
cli/go.sum
10
cli/go.sum
@ -486,6 +486,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -592,6 +594,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -642,9 +646,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -656,6 +664,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
@ -1,10 +1,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
// "fmt"
|
||||
|
||||
// "github.com/Infisical/infisical-merge/packages/api"
|
||||
// "github.com/Infisical/infisical-merge/packages/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -14,13 +10,8 @@ import (
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/gateway"
|
||||
"github.com/Infisical/infisical-merge/packages/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
// "github.com/Infisical/infisical-merge/packages/visualize"
|
||||
// "github.com/rs/zerolog/log"
|
||||
|
||||
// "github.com/go-resty/resty/v2"
|
||||
"github.com/posthog/posthog-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@ -40,6 +31,16 @@ var gatewayCmd = &cobra.Command{
|
||||
util.HandleError(fmt.Errorf("Token not found"))
|
||||
}
|
||||
|
||||
domain, err := cmd.Flags().GetString("domain")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse domain flag")
|
||||
}
|
||||
|
||||
// Try to install systemd service if possible
|
||||
if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil {
|
||||
log.Warn().Msgf("Failed to install systemd service: %v", err)
|
||||
}
|
||||
|
||||
Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION))
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
|
@ -14,6 +14,7 @@ import (
|
||||
"github.com/Infisical/infisical-merge/packages/api"
|
||||
"github.com/Infisical/infisical-merge/packages/systemd"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -54,26 +55,6 @@ func (g *Gateway) ConnectWithRelay() error {
|
||||
return err
|
||||
}
|
||||
relayAddress, relayPort := strings.Split(relayDetails.TurnServerAddress, ":")[0], strings.Split(relayDetails.TurnServerAddress, ":")[1]
|
||||
var conn net.Conn
|
||||
|
||||
// Dial TURN Server
|
||||
if relayPort == "5349" {
|
||||
log.Info().Msgf("Provided relay port %s. Using TLS", relayPort)
|
||||
conn, err = tls.Dial("tcp", relayDetails.TurnServerAddress, &tls.Config{
|
||||
ServerName: relayAddress,
|
||||
})
|
||||
} else {
|
||||
log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort)
|
||||
peerAddr, errPeer := net.ResolveTCPAddr("tcp", relayDetails.TurnServerAddress)
|
||||
if errPeer != nil {
|
||||
return fmt.Errorf("Failed to parse turn server address: %w", err)
|
||||
}
|
||||
conn, err = net.DialTCP("tcp", nil, peerAddr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect with relay server: %w", err)
|
||||
}
|
||||
|
||||
// Start a new TURN Client and wrap our net.Conn in a STUNConn
|
||||
// This allows us to simulate datagram based communication over a net.Conn
|
||||
@ -81,17 +62,42 @@ func (g *Gateway) ConnectWithRelay() error {
|
||||
if os.Getenv("LOG_LEVEL") == "debug" {
|
||||
logger.DefaultLogLevel = logging.LogLevelDebug
|
||||
}
|
||||
cfg := &turn.ClientConfig{
|
||||
|
||||
turnClientCfg := &turn.ClientConfig{
|
||||
STUNServerAddr: relayDetails.TurnServerAddress,
|
||||
TURNServerAddr: relayDetails.TurnServerAddress,
|
||||
Conn: turn.NewSTUNConn(conn),
|
||||
Username: relayDetails.TurnServerUsername,
|
||||
Password: relayDetails.TurnServerPassword,
|
||||
Realm: relayDetails.TurnServerRealm,
|
||||
LoggerFactory: logger,
|
||||
}
|
||||
|
||||
client, err := turn.NewClient(cfg)
|
||||
turnAddr, err := net.ResolveUDPAddr("udp4", relayDetails.TurnServerAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse turn server address: %w", err)
|
||||
}
|
||||
|
||||
// Dial TURN Server
|
||||
if relayPort == "5349" {
|
||||
log.Info().Msgf("Provided relay port %s. Using TLS", relayPort)
|
||||
conn, err := dtls.Dial("udp", turnAddr, &dtls.Config{
|
||||
ServerName: relayAddress,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect with relay server: %w", err)
|
||||
}
|
||||
turnClientCfg.Conn = turn.NewSTUNConn(conn)
|
||||
} else {
|
||||
log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort)
|
||||
conn, err := net.ListenPacket("udp4", turnAddr.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect with relay server: %w", err)
|
||||
}
|
||||
|
||||
turnClientCfg.Conn = conn
|
||||
}
|
||||
|
||||
client, err := turn.NewClient(turnClientCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create relay client: %w", err)
|
||||
}
|
||||
@ -168,7 +174,6 @@ func (g *Gateway) Listen(ctx context.Context) error {
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
NextProtos: []string{"infisical-gateway"},
|
||||
}
|
||||
|
||||
// Setup QUIC listener on the relayConn
|
||||
quicConfig := &quic.Config{
|
||||
EnableDatagrams: true,
|
||||
@ -176,7 +181,6 @@ func (g *Gateway) Listen(ctx context.Context) error {
|
||||
KeepAlivePeriod: 2 * time.Second,
|
||||
}
|
||||
|
||||
g.registerRelayIsActive(ctx, errCh)
|
||||
quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to listen for QUIC: %w", err)
|
||||
@ -185,6 +189,8 @@ func (g *Gateway) Listen(ctx context.Context) error {
|
||||
|
||||
log.Printf("Listener started on %s", quicListener.Addr())
|
||||
|
||||
g.registerRelayIsActive(ctx, errCh)
|
||||
|
||||
log.Info().Msg("Gateway started successfully")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@ -326,7 +332,6 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e
|
||||
failures := 0
|
||||
|
||||
log.Info().Msg("Starting relay connection health check")
|
||||
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
for {
|
||||
@ -335,36 +340,17 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e
|
||||
log.Info().Msg("Stopping relay connection health check")
|
||||
return
|
||||
case <-ticker.C:
|
||||
func() {
|
||||
log.Debug().Msg("Performing relay connection health check")
|
||||
|
||||
if g.client == nil {
|
||||
failures++
|
||||
log.Warn().Int("failures", failures).Msg("TURN client is nil")
|
||||
if failures >= maxFailures {
|
||||
errCh <- fmt.Errorf("relay connection check failed: TURN client is nil")
|
||||
}
|
||||
log.Debug().Msg("Performing relay connection health check")
|
||||
err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp)
|
||||
if err != nil && !strings.Contains(err.Error(), "tls:") {
|
||||
failures++
|
||||
log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions")
|
||||
if failures >= maxFailures {
|
||||
errCh <- fmt.Errorf("relay connection check failed: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// we try to refresh permissions - this is a lightweight operation
|
||||
// that will fail immediately if the UDP connection is broken. good for health check
|
||||
log.Debug().Msg("Refreshing TURN permissions to verify connection")
|
||||
if err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil {
|
||||
failures++
|
||||
log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions")
|
||||
if failures >= maxFailures {
|
||||
errCh <- fmt.Errorf("relay connection check failed: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msg("Successfully refreshed TURN permissions - connection is healthy")
|
||||
if failures > 0 {
|
||||
log.Info().Int("previous_failures", failures).Msg("Relay connection restored")
|
||||
failures = 0
|
||||
}
|
||||
}()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
@ -4,7 +4,6 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
@ -12,12 +11,13 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
|
||||
// "runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener"
|
||||
"github.com/Infisical/infisical-merge/packages/systemd"
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -108,7 +108,7 @@ func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) {
|
||||
}
|
||||
|
||||
func (g *GatewayRelay) Run() error {
|
||||
addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
|
||||
addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse server address: %s", err)
|
||||
}
|
||||
@ -117,13 +117,6 @@ func (g *GatewayRelay) Run() error {
|
||||
// and process them yourself.
|
||||
logger := logging.NewDefaultLeveledLoggerForScope("lt-creds", logging.LogLevelTrace, os.Stdout)
|
||||
|
||||
// Create `numThreads` UDP listeners to pass into pion/turn
|
||||
// pion/turn itself doesn't allocate any UDP sockets, but lets the user pass them in
|
||||
// this allows us to add logging, storage or modify inbound/outbound traffic
|
||||
// UDP listeners share the same local address:port with setting SO_REUSEPORT and the kernel
|
||||
// will load-balance received packets per the IP 5-tuple
|
||||
listenerConfig := udplistener.SetupListenerConfig()
|
||||
|
||||
publicIP := g.Config.PublicIP
|
||||
relayAddressGenerator := &turn.RelayAddressGeneratorPortRange{
|
||||
RelayAddress: net.ParseIP(publicIP), // Claim that we are listening on IP passed by user
|
||||
@ -132,49 +125,54 @@ func (g *GatewayRelay) Run() error {
|
||||
MaxPort: g.Config.RelayMaxPort,
|
||||
}
|
||||
|
||||
threadNum := runtime.NumCPU()
|
||||
listenerConfigs := make([]turn.ListenerConfig, threadNum)
|
||||
var connAddress string
|
||||
for i := 0; i < threadNum; i++ {
|
||||
conn, listErr := listenerConfig.Listen(context.Background(), addr.Network(), addr.String())
|
||||
if listErr != nil {
|
||||
return fmt.Errorf("Failed to allocate TCP listener at %s:%s %s", addr.Network(), addr.String(), listErr)
|
||||
}
|
||||
|
||||
listenerConfigs[i] = turn.ListenerConfig{
|
||||
RelayAddressGenerator: relayAddressGenerator,
|
||||
}
|
||||
|
||||
if g.Config.isTlsEnabled {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
|
||||
|
||||
listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{
|
||||
Certificates: []tls.Certificate{g.Config.tls},
|
||||
ClientCAs: caCertPool,
|
||||
})
|
||||
} else {
|
||||
listenerConfigs[i].Listener = conn
|
||||
}
|
||||
connAddress = conn.Addr().String()
|
||||
}
|
||||
|
||||
loggerF := logging.NewDefaultLoggerFactory()
|
||||
loggerF.DefaultLogLevel = logging.LogLevelDebug
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
|
||||
|
||||
listenerConfigs := make([]turn.ListenerConfig, 0)
|
||||
packetConfigs := make([]turn.PacketConnConfig, 0)
|
||||
|
||||
if g.Config.isTlsEnabled {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
|
||||
dtlsServer, err := dtls.Listen("udp", addr, &dtls.Config{
|
||||
Certificates: []tls.Certificate{g.Config.tls},
|
||||
ClientCAs: caCertPool,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to start dtls server: %w", err)
|
||||
}
|
||||
listenerConfigs = append(listenerConfigs, turn.ListenerConfig{
|
||||
RelayAddressGenerator: relayAddressGenerator,
|
||||
Listener: dtlsServer,
|
||||
})
|
||||
} else {
|
||||
udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to relay udp listener: %w", err)
|
||||
}
|
||||
packetConfigs = append(packetConfigs, turn.PacketConnConfig{
|
||||
RelayAddressGenerator: relayAddressGenerator,
|
||||
PacketConn: udpListener,
|
||||
})
|
||||
}
|
||||
|
||||
server, err := turn.NewServer(turn.ServerConfig{
|
||||
Realm: g.Config.Realm,
|
||||
AuthHandler: turn.LongTermTURNRESTAuthHandler(g.Config.AuthSecret, logger),
|
||||
// PacketConnConfigs is a list of UDP Listeners and the configuration around them
|
||||
ListenerConfigs: listenerConfigs,
|
||||
LoggerFactory: loggerF,
|
||||
ListenerConfigs: listenerConfigs,
|
||||
PacketConnConfigs: packetConfigs,
|
||||
LoggerFactory: loggerF,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to start server: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Msgf("Relay listening on %s\n", connAddress)
|
||||
log.Info().Msgf("Relay listening on %d\n", g.Config.Port)
|
||||
|
||||
// make this compatiable with systemd notify mode
|
||||
systemd.SdNotify(false, systemd.SdNotifyReady)
|
||||
|
82
cli/packages/gateway/systemd.go
Normal file
82
cli/packages/gateway/systemd.go
Normal file
@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const systemdServiceTemplate = `[Unit]
|
||||
Description=Infisical Gateway Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/infisical/gateway.conf
|
||||
ExecStart=/usr/local/bin/infisical gateway
|
||||
Restart=on-failure
|
||||
InaccessibleDirectories=/home
|
||||
PrivateTmp=yes
|
||||
LimitCORE=infinity
|
||||
LimitNOFILE=1000000
|
||||
LimitNPROC=60000
|
||||
LimitRTPRIO=infinity
|
||||
LimitRTTIME=7000000
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
func InstallGatewaySystemdService(token string, domain string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
log.Info().Msg("Skipping systemd service installation - not on Linux")
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
log.Info().Msg("Skipping systemd service installation - not running as root/sudo")
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir := "/etc/infisical"
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
configContent := fmt.Sprintf("INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN=%s\n", token)
|
||||
if domain != "" {
|
||||
configContent += fmt.Sprintf("INFISICAL_API_URL=%s\n", domain)
|
||||
} else {
|
||||
configContent += "INFISICAL_API_URL=\n"
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "gateway.conf")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
servicePath := "/etc/systemd/system/infisical-gateway.service"
|
||||
if _, err := os.Stat(servicePath); err == nil {
|
||||
log.Info().Msg("Systemd service file already exists")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(servicePath, []byte(systemdServiceTemplate), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write systemd service file: %v", err)
|
||||
}
|
||||
|
||||
reloadCmd := exec.Command("systemctl", "daemon-reload")
|
||||
if err := reloadCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to reload systemd: %v", err)
|
||||
}
|
||||
|
||||
log.Info().Msg("Successfully installed systemd service")
|
||||
log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway")
|
||||
log.Info().Msg("To enable the service on boot, run: sudo systemctl enable infisical-gateway")
|
||||
|
||||
return nil
|
||||
}
|
@ -10,6 +10,10 @@ Being a remote-first company, we try to be as async as possible. When an issue a
|
||||
|
||||
In other words, we have almost no (recurring) meetings and prefer written communication or quick Slack huddles.
|
||||
|
||||
## Daily Standup
|
||||
|
||||
Towards the end of each day, everyone on the Engineering and GTM teams should document their progress in the respective Slack standup channels, ensuring the team stays informed of important updates. On the engineering side, if you are working on something that takes longer than 1-2 days, please add an estimated completion date (ECD) for that item in standup specifying when it will be pushed to production.
|
||||
|
||||
## Weekly All-hands
|
||||
|
||||
All-hands is the single recurring meeting that we run every Monday at 8:30am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too).
|
||||
All-hands is the single recurring meeting that we run every Monday at 8:00am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too).
|
||||
|
110
docs/documentation/platform/gateways/gateway-security.mdx
Normal file
110
docs/documentation/platform/gateways/gateway-security.mdx
Normal file
@ -0,0 +1,110 @@
|
||||
---
|
||||
title: "Gateway Security Architecture"
|
||||
sidebarTitle: "Architecture"
|
||||
description: "Understand the security model and tenant isolation of Infisical's Gateway"
|
||||
---
|
||||
|
||||
# Gateway Security Architecture
|
||||
|
||||
The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants.
|
||||
This document explains the internal security architecture and how tenant isolation is maintained.
|
||||
|
||||
## Security Model Overview
|
||||
|
||||
### Private PKI System
|
||||
Each organization (tenant) in Infisical has its own private PKI system consisting of:
|
||||
|
||||
1. **Root CA**: The ultimate trust anchor for the organization
|
||||
2. **Intermediate CAs**:
|
||||
- Client CA: Issues certificates for cloud components
|
||||
- Gateway CA: Issues certificates for gateway instances
|
||||
|
||||
This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain.
|
||||
|
||||
### Certificate Hierarchy
|
||||
```
|
||||
Root CA (Organization Specific)
|
||||
├── Client CA
|
||||
│ └── Client Certificates (Cloud Components)
|
||||
└── Gateway CA
|
||||
└── Gateway Certificates (Gateway Instances)
|
||||
```
|
||||
|
||||
## Communication Security
|
||||
|
||||
### 1. Gateway Registration
|
||||
When a gateway is first deployed:
|
||||
|
||||
1. Establishes initial connection using machine identity token
|
||||
2. Allocates a relay address for communication
|
||||
3. Exchanges certificates through a secure handshake:
|
||||
- Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification
|
||||
|
||||
### 2. Mutual TLS Authentication
|
||||
All communication between gateway and cloud uses mutual TLS (mTLS):
|
||||
|
||||
- **Gateway Authentication**:
|
||||
- Presents certificate signed by organization's Gateway CA
|
||||
- Certificate contains unique identifiers (Organization ID, Gateway ID)
|
||||
- Cloud validates complete certificate chain
|
||||
|
||||
- **Cloud Authentication**:
|
||||
- Presents certificate signed by organization's Client CA
|
||||
- Certificate includes required organizational unit ("gateway-client")
|
||||
- Gateway validates certificate chain back to organization's root CA
|
||||
|
||||
### 3. Relay Communication
|
||||
The relay system provides secure tunneling:
|
||||
|
||||
1. **Connection Establishment**:
|
||||
- Uses QUIC protocol over UDP for efficient, secure communication
|
||||
- Provides built-in encryption, congestion control, and multiplexing
|
||||
- Enables faster connection establishment and reduced latency
|
||||
- Each organization's traffic is isolated using separate relay sessions
|
||||
|
||||
2. **Traffic Isolation**:
|
||||
- Each gateway gets unique relay credentials
|
||||
- Traffic is end-to-end encrypted using QUIC's TLS 1.3
|
||||
- Organization's private keys never leave their environment
|
||||
|
||||
## Tenant Isolation
|
||||
|
||||
### Certificate-Based Isolation
|
||||
- Each organization has unique root CA and intermediate CAs
|
||||
- Certificates contain organization-specific identifiers
|
||||
- Cross-tenant communication is cryptographically impossible
|
||||
|
||||
### Gateway-Project Mapping
|
||||
- Gateways are explicitly mapped to specific projects
|
||||
- Access controls enforce organization boundaries
|
||||
- Project-level permissions determine resource accessibility
|
||||
|
||||
### Resource Access Control
|
||||
1. **Project Verification**:
|
||||
- Gateway verifies project membership
|
||||
- Validates organization ownership
|
||||
- Enforces project-level permissions
|
||||
|
||||
2. **Resource Restrictions**:
|
||||
- Gateways only accept connections to approved resources
|
||||
- Each connection requires explicit project authorization
|
||||
- Resources remain private to their assigned organization
|
||||
|
||||
## Security Measures
|
||||
|
||||
### Certificate Lifecycle
|
||||
- Certificates have limited validity periods
|
||||
- Automatic certificate rotation
|
||||
- Immediate certificate revocation capabilities
|
||||
|
||||
### Monitoring and Verification
|
||||
1. **Continuous Verification**:
|
||||
- Regular heartbeat checks
|
||||
- Certificate chain validation
|
||||
- Connection state monitoring
|
||||
|
||||
2. **Security Controls**:
|
||||
- Automatic connection termination on verification failure
|
||||
- Audit logging of all access attempts
|
||||
- Machine identity based authentication
|
||||
|
@ -203,7 +203,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Gateway",
|
||||
"pages": ["documentation/platform/gateways/overview"]
|
||||
"pages": ["documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security"]
|
||||
},
|
||||
"documentation/platform/project-templates",
|
||||
{
|
||||
|
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1741445498,
|
||||
"narHash": "sha256-F5Em0iv/CxkN5mZ9hRn3vPknpoWdcdCyR0e4WklHwiE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "52e3095f6d812b91b22fb7ad0bfc1ab416453634",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
24
flake.nix
Normal file
24
flake.nix
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
description = "Flake for github:Infisical/infisical repository.";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }: {
|
||||
devShells.aarch64-darwin.default = let
|
||||
pkgs = nixpkgs.legacyPackages.aarch64-darwin;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
git
|
||||
lazygit
|
||||
|
||||
python312Full
|
||||
nodejs_20
|
||||
nodePackages.prettier
|
||||
infisical
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { generateUserBackupKey } from "@app/lib/crypto";
|
||||
|
||||
import { createNotification } from "../notifications";
|
||||
import { generateBackupPDFAsync } from "../utilities/generateBackupPDF";
|
||||
import { Button } from "../v2";
|
||||
|
||||
interface DownloadBackupPDFStepProps {
|
||||
incrementStep: () => void;
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the step of the signup flow where the user downloads the backup pdf
|
||||
* @param {object} obj
|
||||
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
|
||||
* @param {string} obj.email - user's email
|
||||
* @param {string} obj.password - user's password
|
||||
* @param {string} obj.name - user's name
|
||||
* @returns
|
||||
*/
|
||||
export default function DonwloadBackupPDFStep({
|
||||
incrementStep,
|
||||
email,
|
||||
password,
|
||||
name
|
||||
}: DownloadBackupPDFStepProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
|
||||
const handleBackupKeyGenerate = async () => {
|
||||
try {
|
||||
setIsLoading.on();
|
||||
const generatedKey = await generateUserBackupKey(email, password);
|
||||
await generateBackupPDFAsync({
|
||||
generatedKey,
|
||||
personalEmail: email,
|
||||
personalName: name
|
||||
});
|
||||
incrementStep();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to generate backup key"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
|
||||
/>
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={handleBackupKeyGenerate}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -34,12 +34,12 @@ const passwordCheck = async ({
|
||||
const tests = [
|
||||
{
|
||||
name: "tooShort",
|
||||
validator: (pwd: string) => pwd.length >= 14,
|
||||
validator: (pwd: string) => pwd?.length >= 14,
|
||||
setError: setPasswordErrorTooShort
|
||||
},
|
||||
{
|
||||
name: "tooLong",
|
||||
validator: (pwd: string) => pwd.length < 101,
|
||||
validator: (pwd: string) => pwd?.length < 101,
|
||||
setError: setPasswordErrorTooLong
|
||||
},
|
||||
{
|
||||
|
@ -4,19 +4,24 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { User } from "../types";
|
||||
import {
|
||||
AdminGetIdentitiesFilters,
|
||||
AdminGetUsersFilters,
|
||||
AdminSlackConfig,
|
||||
TGetServerRootKmsEncryptionDetails,
|
||||
TServerConfig
|
||||
} from "./types";
|
||||
import { Identity } from "@app/hooks/api/identities/types";
|
||||
|
||||
export const adminStandaloneKeys = {
|
||||
getUsers: "get-users"
|
||||
getUsers: "get-users",
|
||||
getIdentities: "get-identities"
|
||||
};
|
||||
|
||||
export const adminQueryKeys = {
|
||||
serverConfig: () => ["server-config"] as const,
|
||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||
getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const
|
||||
};
|
||||
@ -68,6 +73,28 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
|
||||
return useInfiniteQuery({
|
||||
initialPageParam: 0,
|
||||
queryKey: adminQueryKeys.getIdentities(filters),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await apiRequest.get<{ identities: Identity[] }>(
|
||||
"/api/v1/admin/identity-management/identities",
|
||||
{
|
||||
params: {
|
||||
...filters,
|
||||
offset: pageParam
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data.identities;
|
||||
},
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.length !== 0 ? pages.length * filters.limit : undefined
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAdminSlackConfig = () => {
|
||||
return useQuery({
|
||||
queryKey: adminQueryKeys.getAdminSlackConfig(),
|
||||
|
@ -50,6 +50,12 @@ export type TUpdateAdminSlackConfigDTO = {
|
||||
export type AdminGetUsersFilters = {
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
export type AdminGetIdentitiesFilters = {
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export type AdminSlackConfig = {
|
||||
|
@ -2,6 +2,8 @@ export {
|
||||
useGetAuthToken,
|
||||
useOauthTokenExchange,
|
||||
useResetPassword,
|
||||
useResetPasswordV2,
|
||||
useResetUserPasswordV2,
|
||||
useSelectOrganization,
|
||||
useSendMfaToken,
|
||||
useSendPasswordResetEmail,
|
||||
|
@ -22,12 +22,15 @@ import {
|
||||
LoginLDAPRes,
|
||||
MfaMethod,
|
||||
ResetPasswordDTO,
|
||||
ResetPasswordV2DTO,
|
||||
ResetUserPasswordV2DTO,
|
||||
SendMfaTokenDTO,
|
||||
SetupPasswordDTO,
|
||||
SRP1DTO,
|
||||
SRPR1Res,
|
||||
TOauthTokenExchangeDTO,
|
||||
UserAgentType,
|
||||
UserEncryptionVersion,
|
||||
VerifyMfaTokenDTO,
|
||||
VerifyMfaTokenRes,
|
||||
VerifySignupInviteDTO
|
||||
@ -247,7 +250,10 @@ export const useSendPasswordResetEmail = () => {
|
||||
export const useVerifyPasswordResetCode = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ email, code }: { email: string; code: string }) => {
|
||||
const { data } = await apiRequest.post("/api/v1/password/email/password-reset-verify", {
|
||||
const { data } = await apiRequest.post<{
|
||||
token: string;
|
||||
userEncryptionVersion: UserEncryptionVersion;
|
||||
}>("/api/v1/password/email/password-reset-verify", {
|
||||
email,
|
||||
code
|
||||
});
|
||||
@ -302,6 +308,26 @@ export const useResetPassword = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetPasswordV2 = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (details: ResetPasswordV2DTO) => {
|
||||
await apiRequest.post("/api/v2/password/password-reset", details, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${details.verificationToken}`
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetUserPasswordV2 = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (details: ResetUserPasswordV2DTO) => {
|
||||
await apiRequest.post("/api/v2/password/user/password-reset", details);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const changePassword = async (details: ChangePasswordDTO) => {
|
||||
const { data } = await apiRequest.post("/api/v1/password/change-password", details);
|
||||
return data;
|
||||
|
@ -3,6 +3,11 @@ export type GetAuthTokenAPI = {
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export enum UserEncryptionVersion {
|
||||
V1 = 1,
|
||||
V2 = 2
|
||||
}
|
||||
|
||||
export type SendMfaTokenDTO = {
|
||||
email: string;
|
||||
};
|
||||
@ -136,6 +141,16 @@ export type ResetPasswordDTO = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordV2DTO = {
|
||||
newPassword: string;
|
||||
verificationToken: string;
|
||||
};
|
||||
|
||||
export type ResetUserPasswordV2DTO = {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type SetupPasswordDTO = {
|
||||
protectedKey: string;
|
||||
protectedKeyIV: string;
|
||||
|
@ -74,6 +74,107 @@ export const queryClient = new QueryClient({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) {
|
||||
createNotification(
|
||||
{
|
||||
title: "Forbidden Access",
|
||||
type: "error",
|
||||
text: `${serverResponse.message}.`,
|
||||
callToAction: serverResponse?.details?.missingPermissions?.length ? (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant="outline_bg" size="xs">
|
||||
Show more
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent title="Missing Permission">
|
||||
<div className="flex flex-col gap-2">
|
||||
{serverResponse.details?.missingPermissions?.map((el, index) => {
|
||||
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
|
||||
return (
|
||||
<div
|
||||
key={`Forbidden-error-details-${index + 1}`}
|
||||
className="rounded-md border border-gray-600 p-4"
|
||||
>
|
||||
<div>
|
||||
You are not authorized to perform the <b>{el.action}</b> action on the{" "}
|
||||
<b>{el.subject}</b> resource.{" "}
|
||||
{hasConditions &&
|
||||
"Your permission does not allow access to the following conditions:"}
|
||||
</div>
|
||||
{hasConditions && (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
|
||||
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
|
||||
const operators = (
|
||||
el.conditions as Record<
|
||||
string,
|
||||
| string
|
||||
| { [K in PermissionConditionOperators]: string | string[] }
|
||||
>
|
||||
)[field];
|
||||
|
||||
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
|
||||
if (typeof operators === "string") {
|
||||
return (
|
||||
<li
|
||||
key={`Forbidden-error-details-${index + 1}-${
|
||||
fieldIndex + 1
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold capitalize">
|
||||
{formattedFieldName}
|
||||
</span>{" "}
|
||||
<span className="text-mineshaft-200">equal to</span>{" "}
|
||||
<span className="text-yellow-600">{operators}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return Object.keys(operators).map((operator, operatorIndex) => (
|
||||
<li
|
||||
key={`Forbidden-error-details-${index + 1}-${
|
||||
fieldIndex + 1
|
||||
}-${operatorIndex + 1}`}
|
||||
>
|
||||
<span className="font-bold capitalize">
|
||||
{formattedFieldName}
|
||||
</span>{" "}
|
||||
<span className="text-mineshaft-200">
|
||||
{
|
||||
formatedConditionsOperatorNames[
|
||||
operator as PermissionConditionOperators
|
||||
]
|
||||
}
|
||||
</span>{" "}
|
||||
<span className="text-yellow-600">
|
||||
{operators[
|
||||
operator as PermissionConditionOperators
|
||||
].toString()}
|
||||
</span>
|
||||
</li>
|
||||
));
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
) : undefined,
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{ closeOnClick: false }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (serverResponse?.error === ApiErrorTypes.ForbiddenError) {
|
||||
createNotification(
|
||||
{
|
||||
|
@ -44,6 +44,7 @@ export type {
|
||||
|
||||
export enum ApiErrorTypes {
|
||||
ValidationError = "ValidationFailure",
|
||||
PermissionBoundaryError = "PermissionBoundaryError",
|
||||
BadRequestError = "BadRequest",
|
||||
UnauthorizedError = "UnauthorizedError",
|
||||
ForbiddenError = "PermissionDenied"
|
||||
@ -74,4 +75,17 @@ export type TApiErrors =
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
statusCode: 403;
|
||||
message: string;
|
||||
error: ApiErrorTypes.PermissionBoundaryError;
|
||||
details: {
|
||||
missingPermissions: {
|
||||
action: string;
|
||||
subject: string;
|
||||
conditions: Record<string, Record<string, string>>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
@ -30,10 +30,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { envConfig } from "@app/config/env";
|
||||
import { useOrganization, useSubscription, useUser } from "@app/context";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import {
|
||||
useGetOrganizations,
|
||||
@ -50,6 +53,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
|
||||
|
||||
import { MenuIconButton } from "../MenuIconButton";
|
||||
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
|
||||
|
||||
const getPlan = (subscription: SubscriptionPlan) => {
|
||||
if (subscription.dynamicSecret) return "Enterprise Plan";
|
||||
@ -77,6 +81,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [
|
||||
<FontAwesomeIcon key={4} className="pr-4 text-sm" icon={faEnvelope} />,
|
||||
"Email Support",
|
||||
"mailto:support@infisical.com"
|
||||
],
|
||||
[
|
||||
<FontAwesomeIcon key={5} className="pr-4 text-sm" icon={faUsers} />,
|
||||
"Instance Admins",
|
||||
"server-admins"
|
||||
]
|
||||
];
|
||||
|
||||
@ -89,6 +98,7 @@ export const MinimizedOrgSidebar = () => {
|
||||
const [openSupport, setOpenSupport] = useState(false);
|
||||
const [openUser, setOpenUser] = useState(false);
|
||||
const [openOrg, setOpenOrg] = useState(false);
|
||||
const [showAdminsModal, setShowAdminsModal] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { mutateAsync } = useGetOrgTrialUrl();
|
||||
@ -410,21 +420,39 @@ export const MinimizedOrgSidebar = () => {
|
||||
side="right"
|
||||
className="p-1"
|
||||
>
|
||||
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => (
|
||||
<DropdownMenuItem key={url as string}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={String(url)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => {
|
||||
if (url === "server-admins" && isInfisicalCloud()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem key={url as string}>
|
||||
{url === "server-admins" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdminsModal(true)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={String(url)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{envConfig.PLATFORM_VERSION && (
|
||||
<div className="mb-2 mt-2 w-full cursor-default pl-5 text-sm duration-200 hover:text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faInfo} className="mr-4 px-[0.1rem]" />
|
||||
@ -540,6 +568,13 @@ export const MinimizedOrgSidebar = () => {
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
|
||||
<ModalContent title="Server Administrators" subTitle="View all server administrators">
|
||||
<div className="mb-2">
|
||||
<ServerAdminsPanel />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CreateOrgModal
|
||||
isOpen={popUp?.createOrg?.isOpen}
|
||||
onClose={() => handlePopUpToggle("createOrg", false)}
|
||||
|
85
frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx
Normal file
85
frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetOrgUsers } from "@app/hooks/api";
|
||||
|
||||
export const ServerAdminsPanel = () => {
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || "");
|
||||
|
||||
const adminUsers = orgUsers?.filter((orgUser) => {
|
||||
const isSuperAdmin = orgUser.user.superAdmin;
|
||||
const matchesSearch = debouncedSearchTerm
|
||||
? orgUser.user.email?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.firstName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.lastName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
|
||||
: true;
|
||||
return isSuperAdmin && matchesSearch;
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && (!adminUsers || adminUsers.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-4 px-4">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search server admins..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 px-2">
|
||||
<TableContainer className="flex max-h-[30vh] flex-col overflow-auto">
|
||||
<Table className="w-full table-fixed">
|
||||
<THead className="sticky top-0 bg-bunker-800">
|
||||
<Tr>
|
||||
<Th className="w-1/2">Name</Th>
|
||||
<Th className="w-1/2">Email</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="admins" />}
|
||||
{!isPending &&
|
||||
adminUsers?.map(({ user }) => {
|
||||
const name =
|
||||
user.firstName || user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.username;
|
||||
return (
|
||||
<Tr key={`admin-${user.id}`}>
|
||||
<Td className="w-1/2 break-words">{name}</Td>
|
||||
<Td className="w-1/2 break-words">{user.email}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{isEmpty && (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-mineshaft-400">
|
||||
No server administrators found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel";
|
||||
import { IntegrationPanel } from "./components/IntegrationPanel";
|
||||
import { RateLimitPanel } from "./components/RateLimitPanel";
|
||||
import { UserPanel } from "./components/UserPanel";
|
||||
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
|
||||
|
||||
enum TabSections {
|
||||
Settings = "settings",
|
||||
@ -42,6 +43,7 @@ enum TabSections {
|
||||
RateLimit = "rate-limit",
|
||||
Integrations = "integrations",
|
||||
Users = "users",
|
||||
Identities = "identities",
|
||||
Kmip = "kmip"
|
||||
}
|
||||
|
||||
@ -164,6 +166,7 @@ export const OverviewPage = () => {
|
||||
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
|
||||
<Tab value={TabSections.Integrations}>Integrations</Tab>
|
||||
<Tab value={TabSections.Users}>Users</Tab>
|
||||
<Tab value={TabSections.Identities}>Identities</Tab>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanel value={TabSections.Settings}>
|
||||
@ -409,6 +412,9 @@ export const OverviewPage = () => {
|
||||
<TabPanel value={TabSections.Users}>
|
||||
<UserPanel />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<IdentityPanel />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
|
||||
|
||||
const IdentityPanelTable = () => {
|
||||
const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
}
|
||||
);
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchIdentityFilter}
|
||||
onChange={(e) => setSearchIdentityFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search identities by name..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="identities" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((identities) =>
|
||||
identities.map(({ name, id }) => (
|
||||
<Tr key={`identity-${id}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const IdentityPanel = () => (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
</div>
|
||||
<IdentityPanelTable />
|
||||
</div>
|
||||
);
|
@ -1,6 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faUsers, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faEllipsis,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faUsers,
|
||||
faUserShield
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@ -8,7 +16,13 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
@ -17,11 +31,7 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useUser } from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
@ -48,25 +58,63 @@ const UserPanelTable = ({
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [adminsOnly, setAdminsOnly] = useState(false);
|
||||
const { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debounedSearchTerm
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
const isTableFiltered = Boolean(adminsOnly);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Users"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setAdminsOnly(!adminsOnly);
|
||||
}}
|
||||
icon={adminsOnly && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faUserShield} className="text-yellow-700" />
|
||||
<span>Server Admins</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -8,16 +7,13 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF";
|
||||
// TODO(akhilmhdh): rewrite this into module functions in lib
|
||||
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
||||
import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto";
|
||||
|
||||
import { DownloadBackupKeys } from "./components/DownloadBackupKeys";
|
||||
import { generateUserPassKey } from "@app/lib/crypto";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
@ -34,25 +30,17 @@ const formSchema = z
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
enum SignupSteps {
|
||||
DetailsForm = "details-form",
|
||||
BackupKey = "backup-key"
|
||||
}
|
||||
|
||||
export const SignUpPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const [step, setStep] = useState(SignupSteps.DetailsForm);
|
||||
|
||||
const { config } = useServerConfig();
|
||||
const { mutateAsync: createAdminUser } = useCreateAdminUser();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
@ -84,7 +72,7 @@ export const SignUpPage = () => {
|
||||
// Will be refactored in next iteration to make it url based rather than local storage ones
|
||||
// Part of migration to nextjs 14
|
||||
localStorage.setItem("orgData.id", res.organization.id);
|
||||
setStep(SignupSteps.BackupKey);
|
||||
navigate({ to: "/admin" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
@ -94,27 +82,7 @@ export const SignUpPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupKeyGenerate = async () => {
|
||||
try {
|
||||
const { email, password, firstName, lastName } = getValues();
|
||||
const generatedKey = await generateUserBackupKey(email, password);
|
||||
await generateBackupPDFAsync({
|
||||
generatedKey,
|
||||
personalEmail: email,
|
||||
personalName: `${firstName} ${lastName}`
|
||||
});
|
||||
navigate({ to: "/admin" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to generate backup"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.initialized && step === SignupSteps.DetailsForm)
|
||||
return <ContentLoader text="Redirecting to admin page..." />;
|
||||
if (config?.initialized) return <ContentLoader text="Redirecting to admin page..." />;
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
|
||||
@ -127,56 +95,28 @@ export const SignUpPage = () => {
|
||||
</Helmet>
|
||||
<div className="flex items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === SignupSteps.DetailsForm && (
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
<div className="pt-4 text-4xl">Welcome to Infisical</div>
|
||||
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="First name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Last name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
<div className="pt-4 text-4xl">Welcome to Infisical</div>
|
||||
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
name="firstName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Email"
|
||||
label="First name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
@ -186,56 +126,66 @@ export const SignUpPage = () => {
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
name="lastName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
label="Last name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Confirm password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="mt-4"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
{step === SignupSteps.BackupKey && (
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-2"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<DownloadBackupKeys onGenerate={handleBackupKeyGenerate} />
|
||||
</motion.div>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Confirm password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="mt-4"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,56 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onGenerate: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
|
||||
/>
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading.on();
|
||||
await onGenerate();
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { DownloadBackupKeys } from "./DownloadBackupKeys";
|
@ -1,396 +1,75 @@
|
||||
import crypto from "crypto";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
import InputField from "@app/components/basic/InputField";
|
||||
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useResetPassword, useVerifyPasswordResetCode } from "@app/hooks/api";
|
||||
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
|
||||
import { ConfirmEmailStep } from "./components/ConfirmEmailStep";
|
||||
import { EnterPasswordStep } from "./components/EnterPasswordStep";
|
||||
import { InputBackupKeyStep } from "./components/InputBackupKeyStep";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
enum Steps {
|
||||
ConfirmEmail = 1,
|
||||
InputBackupKey = 2,
|
||||
EnterNewPassword = 3
|
||||
}
|
||||
|
||||
const formData = z.object({
|
||||
verificationToken: z.string(),
|
||||
privateKey: z.string(),
|
||||
userEncryptionVersion: z.nativeEnum(UserEncryptionVersion)
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
export const PasswordResetPage = () => {
|
||||
const [verificationToken, setVerificationToken] = useState("");
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupKey, setBackupKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [backupKeyError, setBackupKeyError] = useState(false);
|
||||
const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(false);
|
||||
const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false);
|
||||
const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(false);
|
||||
const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(false);
|
||||
const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false);
|
||||
const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false);
|
||||
const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false);
|
||||
const [passwordErrorBreached, setPasswordErrorBreached] = useState(false);
|
||||
const { watch, setValue } = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
|
||||
const verificationToken = watch("verificationToken");
|
||||
const encryptionVersion = watch("userEncryptionVersion");
|
||||
const privateKey = watch("privateKey");
|
||||
|
||||
const [step, setStep] = useState<Steps>(Steps.ConfirmEmail);
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
|
||||
const {
|
||||
mutateAsync: verifyPasswordResetCodeMutateAsync,
|
||||
isPending: isVerifyPasswordResetLoading
|
||||
} = useVerifyPasswordResetCode();
|
||||
const { mutateAsync: resetPasswordMutateAsync } = useResetPassword();
|
||||
|
||||
const parsedUrl = search;
|
||||
const token = parsedUrl.token as string;
|
||||
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
|
||||
|
||||
// Decrypt the private key with a backup key
|
||||
const getEncryptedKeyHandler = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const result = await getBackupEncryptedPrivateKey({ verificationToken });
|
||||
|
||||
setPrivateKey(
|
||||
Aes256Gcm.decrypt({
|
||||
ciphertext: result.encryptedPrivateKey,
|
||||
iv: result.iv,
|
||||
tag: result.tag,
|
||||
secret: backupKey
|
||||
})
|
||||
);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setBackupKeyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
// If everything is correct, reset the password
|
||||
const resetPasswordHandler = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const errorCheck = await passwordCheck({
|
||||
password: newPassword,
|
||||
setPasswordErrorTooShort,
|
||||
setPasswordErrorTooLong,
|
||||
setPasswordErrorNoLetterChar,
|
||||
setPasswordErrorNoNumOrSpecialChar,
|
||||
setPasswordErrorRepeatedChar,
|
||||
setPasswordErrorEscapeChar,
|
||||
setPasswordErrorLowEntropy,
|
||||
setPasswordErrorBreached
|
||||
});
|
||||
|
||||
if (!errorCheck) {
|
||||
client.init(
|
||||
{
|
||||
username: email,
|
||||
password: newPassword
|
||||
},
|
||||
async () => {
|
||||
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password: newPassword,
|
||||
salt: result.salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
|
||||
// create encrypted private key by encrypting the private
|
||||
// key with the symmetric key [key]
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: privateKey,
|
||||
secret: key
|
||||
});
|
||||
|
||||
// create the protected key by encrypting the symmetric key
|
||||
// [key] with the derived key
|
||||
const {
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: key.toString("hex"),
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
await resetPasswordMutateAsync({
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier,
|
||||
verificationToken,
|
||||
password: newPassword
|
||||
});
|
||||
|
||||
navigate({ to: "/login" });
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Click a button to confirm email
|
||||
const stepConfirmEmail = (
|
||||
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
|
||||
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Confirm your email
|
||||
</p>
|
||||
<img
|
||||
src="/images/envelope.svg"
|
||||
style={{ height: "262px", width: "410px" }}
|
||||
alt="verify email"
|
||||
/>
|
||||
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await verifyPasswordResetCodeMutateAsync({
|
||||
email,
|
||||
code: token
|
||||
});
|
||||
|
||||
setVerificationToken(response.token);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
navigate({ to: "/email-not-verified" });
|
||||
}
|
||||
}}
|
||||
isLoading={isVerifyPasswordResetLoading}
|
||||
size="lg"
|
||||
>
|
||||
Confirm Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Input backup key
|
||||
const stepInputBackupKey = (
|
||||
<form
|
||||
onSubmit={getEncryptedKeyHandler}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter your backup key
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
You can find it in your emergency kit. You had to download the emergency kit during
|
||||
signup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<InputField
|
||||
label="Backup Key"
|
||||
onChangeHandler={setBackupKey}
|
||||
type="password"
|
||||
value={backupKey}
|
||||
placeholder=""
|
||||
isRequired
|
||||
error={backupKeyError}
|
||||
errorText="Something is wrong with the backup key"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" size="lg">
|
||||
Submit Backup Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
// Enter new password
|
||||
const stepEnterNewPassword = (
|
||||
<form
|
||||
onSubmit={resetPasswordHandler}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter new password
|
||||
</p>
|
||||
<div className="mt-1 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
Make sure you save it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<InputField
|
||||
label="New Password"
|
||||
onChangeHandler={(password) => {
|
||||
setNewPassword(password);
|
||||
passwordCheck({
|
||||
password,
|
||||
setPasswordErrorTooShort,
|
||||
setPasswordErrorTooLong,
|
||||
setPasswordErrorNoLetterChar,
|
||||
setPasswordErrorNoNumOrSpecialChar,
|
||||
setPasswordErrorRepeatedChar,
|
||||
setPasswordErrorEscapeChar,
|
||||
setPasswordErrorLowEntropy,
|
||||
setPasswordErrorBreached
|
||||
});
|
||||
}}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
isRequired
|
||||
error={
|
||||
passwordErrorTooShort &&
|
||||
passwordErrorTooLong &&
|
||||
passwordErrorNoLetterChar &&
|
||||
passwordErrorNoNumOrSpecialChar &&
|
||||
passwordErrorRepeatedChar &&
|
||||
passwordErrorEscapeChar &&
|
||||
passwordErrorLowEntropy &&
|
||||
passwordErrorBreached
|
||||
}
|
||||
autoComplete="new-password"
|
||||
id="new-password"
|
||||
/>
|
||||
</div>
|
||||
{passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached ? (
|
||||
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooShort ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at least 14 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooLong ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at most 100 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoLetterChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at least 1 letter character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoNumOrSpecialChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
|
||||
} text-sm`}
|
||||
>
|
||||
at least 1 number or special character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorRepeatedChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at most 3 repeated, consecutive characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorEscapeChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
No escape characters allowed.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorLowEntropy ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
Password contains personal info.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorBreached ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
Password was found in a data breach.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2" />
|
||||
)}
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" onClick={() => setLoading(true)} size="lg" isLoading={loading}>
|
||||
Submit New Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center bg-bunker-800">
|
||||
{step === 1 && stepConfirmEmail}
|
||||
{step === 2 && stepInputBackupKey}
|
||||
{step === 3 && stepEnterNewPassword}
|
||||
{step === Steps.ConfirmEmail && (
|
||||
<ConfirmEmailStep
|
||||
onComplete={(verifyToken, userEncryptionVersion) => {
|
||||
setValue("verificationToken", verifyToken);
|
||||
setValue("userEncryptionVersion", userEncryptionVersion);
|
||||
|
||||
if (userEncryptionVersion === UserEncryptionVersion.V2) {
|
||||
setStep(Steps.EnterNewPassword);
|
||||
} else {
|
||||
setStep(Steps.InputBackupKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === Steps.InputBackupKey && (
|
||||
<InputBackupKeyStep
|
||||
verificationToken={verificationToken}
|
||||
onComplete={(key) => {
|
||||
setValue("privateKey", key);
|
||||
setStep(Steps.EnterNewPassword);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === Steps.EnterNewPassword && (
|
||||
<EnterPasswordStep
|
||||
verificationToken={verificationToken}
|
||||
privateKey={privateKey}
|
||||
encryptionVersion={encryptionVersion}
|
||||
onComplete={() => {
|
||||
navigate({ to: "/login" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,54 @@
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useVerifyPasswordResetCode } from "@app/hooks/api";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
type Props = {
|
||||
onComplete: (verificationToken: string, encryptionVersion: UserEncryptionVersion) => void;
|
||||
};
|
||||
|
||||
export const ConfirmEmailStep = ({ onComplete }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
const { token, to: email } = search;
|
||||
|
||||
const {
|
||||
mutateAsync: verifyPasswordResetCodeMutateAsync,
|
||||
isPending: isVerifyPasswordResetLoading
|
||||
} = useVerifyPasswordResetCode();
|
||||
return (
|
||||
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
|
||||
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Confirm your email
|
||||
</p>
|
||||
<img
|
||||
src="/images/envelope.svg"
|
||||
style={{ height: "262px", width: "410px" }}
|
||||
alt="verify email"
|
||||
/>
|
||||
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await verifyPasswordResetCodeMutateAsync({
|
||||
email,
|
||||
code: token
|
||||
});
|
||||
|
||||
onComplete(response.token, response.userEncryptionVersion);
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
navigate({ to: "/email-not-verified" });
|
||||
}
|
||||
}}
|
||||
isLoading={isVerifyPasswordResetLoading}
|
||||
size="lg"
|
||||
>
|
||||
Confirm Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,325 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import { z } from "zod";
|
||||
|
||||
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useResetPassword, useResetPasswordV2 } from "@app/hooks/api";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
const formData = z.object({
|
||||
password: z.string(),
|
||||
passwordErrorTooShort: z.boolean().optional(),
|
||||
passwordErrorTooLong: z.boolean().optional(),
|
||||
passwordErrorNoLetterChar: z.boolean().optional(),
|
||||
passwordErrorNoNumOrSpecialChar: z.boolean().optional(),
|
||||
passwordErrorRepeatedChar: z.boolean().optional(),
|
||||
passwordErrorEscapeChar: z.boolean().optional(),
|
||||
passwordErrorLowEntropy: z.boolean().optional(),
|
||||
passwordErrorBreached: z.boolean()
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
type Props = {
|
||||
verificationToken: string;
|
||||
privateKey: string;
|
||||
encryptionVersion: UserEncryptionVersion;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export const EnterPasswordStep = ({
|
||||
verificationToken,
|
||||
encryptionVersion,
|
||||
privateKey,
|
||||
onComplete
|
||||
}: Props) => {
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
const { to: email } = search;
|
||||
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
const { mutateAsync: resetPassword, isPending: isLoading } = useResetPassword();
|
||||
const { mutateAsync: resetPasswordV2, isPending: isLoadingV2 } = useResetPasswordV2();
|
||||
|
||||
const passwordErrorTooShort = watch("passwordErrorTooShort");
|
||||
const passwordErrorTooLong = watch("passwordErrorTooLong");
|
||||
const passwordErrorNoLetterChar = watch("passwordErrorNoLetterChar");
|
||||
const passwordErrorNoNumOrSpecialChar = watch("passwordErrorNoNumOrSpecialChar");
|
||||
const passwordErrorRepeatedChar = watch("passwordErrorRepeatedChar");
|
||||
const passwordErrorEscapeChar = watch("passwordErrorEscapeChar");
|
||||
const passwordErrorLowEntropy = watch("passwordErrorLowEntropy");
|
||||
const passwordErrorBreached = watch("passwordErrorBreached");
|
||||
|
||||
const isPasswordError =
|
||||
passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached;
|
||||
|
||||
const handlePasswordCheck = async (checkPassword: string) => {
|
||||
const errorCheck = await passwordCheck({
|
||||
password: checkPassword,
|
||||
setPasswordErrorTooShort: (v) => setValue("passwordErrorTooShort", v),
|
||||
setPasswordErrorTooLong: (v) => setValue("passwordErrorTooLong", v),
|
||||
setPasswordErrorNoLetterChar: (v) => setValue("passwordErrorNoLetterChar", v),
|
||||
setPasswordErrorNoNumOrSpecialChar: (v) => setValue("passwordErrorNoNumOrSpecialChar", v),
|
||||
setPasswordErrorRepeatedChar: (v) => setValue("passwordErrorRepeatedChar", v),
|
||||
setPasswordErrorEscapeChar: (v) => setValue("passwordErrorEscapeChar", v),
|
||||
setPasswordErrorLowEntropy: (v) => setValue("passwordErrorLowEntropy", v),
|
||||
setPasswordErrorBreached: (v) => setValue("passwordErrorBreached", v)
|
||||
});
|
||||
|
||||
return errorCheck;
|
||||
};
|
||||
|
||||
const resetPasswordHandler = async (data: TFormData) => {
|
||||
const errorCheck = await handlePasswordCheck(data.password);
|
||||
|
||||
if (errorCheck) return;
|
||||
|
||||
if (encryptionVersion === UserEncryptionVersion.V2) {
|
||||
await resetPasswordV2({
|
||||
newPassword: data.password,
|
||||
verificationToken
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
client.init(
|
||||
{
|
||||
username: email,
|
||||
password: data.password
|
||||
},
|
||||
async () => {
|
||||
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password: data.password,
|
||||
salt: result.salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
|
||||
// create encrypted private key by encrypting the private
|
||||
// key with the symmetric key [key]
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: privateKey,
|
||||
secret: key
|
||||
});
|
||||
|
||||
// create the protected key by encrypting the symmetric key
|
||||
// [key] with the derived key
|
||||
const {
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: key.toString("hex"),
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
await resetPassword({
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier,
|
||||
verificationToken,
|
||||
password: data.password
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(resetPasswordHandler)}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter new password
|
||||
</p>
|
||||
<div className="mt-1 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
Make sure you save it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
label="New Password"
|
||||
isRequired
|
||||
isError={isPasswordError}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handlePasswordCheck(e.target.value);
|
||||
}}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached ? (
|
||||
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooShort ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at least 14 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooLong ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at most 100 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoLetterChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at least 1 letter character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoNumOrSpecialChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
|
||||
} text-sm`}
|
||||
>
|
||||
at least 1 number or special character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorRepeatedChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at most 3 repeated, consecutive characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorEscapeChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
No escape characters allowed.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorLowEntropy ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
Password contains personal info.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorBreached ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
Password was found in a data breach.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2" />
|
||||
)}
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting || isLoading || isLoadingV2}
|
||||
isDisabled={isSubmitting || isLoading || isLoadingV2}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -0,0 +1,87 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
|
||||
|
||||
type Props = {
|
||||
verificationToken: string;
|
||||
onComplete: (privateKey: string) => void;
|
||||
};
|
||||
|
||||
const formData = z.object({
|
||||
backupKey: z.string()
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
export const InputBackupKeyStep = ({ verificationToken, onComplete }: Props) => {
|
||||
const { control, handleSubmit, setError } = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
|
||||
const getEncryptedKeyHandler = async (data: z.infer<typeof formData>) => {
|
||||
try {
|
||||
const result = await getBackupEncryptedPrivateKey({ verificationToken });
|
||||
|
||||
const privateKey = Aes256Gcm.decrypt({
|
||||
ciphertext: result.encryptedPrivateKey,
|
||||
iv: result.iv,
|
||||
tag: result.tag,
|
||||
secret: data.backupKey
|
||||
});
|
||||
|
||||
onComplete(privateKey);
|
||||
// setStep(3);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("backupKey", { message: "Failed to decrypt private key" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(getEncryptedKeyHandler)}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100">
|
||||
Enter your backup key
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-full px-4 text-center text-sm text-gray-400 sm:max-w-md">
|
||||
You can find it in your emergency kit. You had to download the emergency kit during
|
||||
signup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="backupKey"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Backup Key"
|
||||
>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="08af467b815ffa412f2c98cc3326acdb"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" colorSchema="secondary">
|
||||
Submit Backup Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -4,7 +4,7 @@ import crypto from "crypto";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
@ -16,7 +16,6 @@ import InputField from "@app/components/basic/InputField";
|
||||
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button } from "@app/components/v2";
|
||||
@ -54,8 +53,6 @@ export const SignupInvitePage = () => {
|
||||
const [lastNameError, setLastNameError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [, setBackupKeyError] = useState(false);
|
||||
const [, setBackupKeyIssued] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
@ -205,7 +202,9 @@ export const SignupInvitePage = () => {
|
||||
|
||||
localStorage.setItem("orgData.id", orgId);
|
||||
|
||||
setStep(3);
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
};
|
||||
|
||||
await completeSignupFlow();
|
||||
@ -367,44 +366,6 @@ export const SignupInvitePage = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Step 4 of the sign up process (download the emergency kit pdf)
|
||||
const step4 = (
|
||||
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 pb-6 pt-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<p className="flex justify-center bg-gradient-to-br from-white to-mineshaft-300 bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Save your Emergency Kit
|
||||
</p>
|
||||
<div className="text-md mt-4 flex w-full max-w-md flex-col items-center justify-center rounded-md px-2 text-gray-400 md:mt-8">
|
||||
<div>
|
||||
If you get locked out of your account, your Emergency Kit is the only way to sign in.
|
||||
</div>
|
||||
<div className="mt-3">We recommend you download it and keep it somewhere safe.</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-xs flex-row items-center rounded-md bg-white/10 p-2 text-gray-400 md:max-w-md">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-4 text-4xl" />
|
||||
It contains your Secret Key which we cannot access or recover for you if you lose it.
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await issueBackupKey({
|
||||
email,
|
||||
password,
|
||||
personalName: `${firstName} ${lastName}`,
|
||||
setBackupKeyError,
|
||||
setBackupKeyIssued
|
||||
});
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Helmet>
|
||||
@ -425,7 +386,8 @@ export const SignupInvitePage = () => {
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
|
||||
</div>
|
||||
</Link>
|
||||
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4}
|
||||
{step === 1 && stepConfirmEmail}
|
||||
{step === 2 && main}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import CodeInputStep from "@app/components/auth/CodeInputStep";
|
||||
import DownloadBackupPDF from "@app/components/auth/DonwloadBackupPDFStep";
|
||||
import EnterEmailStep from "@app/components/auth/EnterEmailStep";
|
||||
import InitialSignupStep from "@app/components/auth/InitialSignupStep";
|
||||
import TeamInviteStep from "@app/components/auth/TeamInviteStep";
|
||||
@ -72,7 +71,7 @@ export const SignUpPage = () => {
|
||||
incrementStep();
|
||||
}
|
||||
|
||||
if (!serverDetails?.emailConfigured && step === 5) {
|
||||
if (!serverDetails?.emailConfigured && step === 4) {
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
@ -119,17 +118,6 @@ export const SignUpPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (registerStep === 4) {
|
||||
return (
|
||||
<DownloadBackupPDF
|
||||
incrementStep={incrementStep}
|
||||
email={email}
|
||||
password={password}
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serverDetails?.emailConfigured) {
|
||||
return <TeamInviteStep />;
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import { jwtDecode } from "jwt-decode";
|
||||
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
|
||||
import { BackupPDFStep } from "./components/BackupPDFStep";
|
||||
import { EmailConfirmationStep } from "./components/EmailConfirmationStep";
|
||||
import { UserInfoSSOStep } from "./components/UserInfoSSOStep";
|
||||
|
||||
@ -57,14 +56,9 @@ export const SignupSsoPage = () => {
|
||||
providerOrganizationName={organizationName}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setStep={setStep}
|
||||
providerAuthToken={token}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
|
@ -1,71 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
interface DownloadBackupPDFStepProps {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the step of the signup flow where the user downloads the backup pdf
|
||||
* @param {object} obj
|
||||
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
|
||||
* @param {string} obj.email - user's email
|
||||
* @param {string} obj.password - user's password
|
||||
* @param {string} obj.name - user's name
|
||||
* @returns
|
||||
*/
|
||||
export const BackupPDFStep = ({ email, password, name }: DownloadBackupPDFStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-3 pt-1 text-2xl text-bunker-200" />
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await issueBackupKey({
|
||||
email,
|
||||
password,
|
||||
personalName: name,
|
||||
setBackupKeyError: () => {},
|
||||
setBackupKeyIssued: () => {}
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
{" "}
|
||||
Download PDF{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { BackupPDFStep } from "./BackupPDFStep";
|
@ -2,6 +2,7 @@ import crypto from "crypto";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import nacl from "tweetnacl";
|
||||
import { encodeBase64 } from "tweetnacl-util";
|
||||
@ -17,12 +18,12 @@ import { useToggle } from "@app/hooks";
|
||||
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
username: string;
|
||||
password: string;
|
||||
setPassword: (value: string) => void;
|
||||
@ -50,7 +51,6 @@ export const UserInfoSSOStep = ({
|
||||
providerOrganizationName,
|
||||
password,
|
||||
setPassword,
|
||||
setStep,
|
||||
providerAuthToken
|
||||
}: Props) => {
|
||||
const [nameError, setNameError] = useState(false);
|
||||
@ -63,6 +63,7 @@ export const UserInfoSSOStep = ({
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const randomPassword = crypto.randomBytes(32).toString("hex");
|
||||
@ -202,7 +203,9 @@ export const UserInfoSSOStep = ({
|
||||
}
|
||||
|
||||
localStorage.setItem("orgData.id", orgId);
|
||||
setStep(2);
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
console.error(error);
|
||||
|
@ -72,8 +72,9 @@ export const VerifyEmailPage = () => {
|
||||
Forgot your password?
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max justify-center text-sm text-gray-400">
|
||||
You will need your emergency kit. Enter your email to start account recovery.
|
||||
<p className="flex w-max justify-center text-center text-sm text-gray-400">
|
||||
Enter your email to start the password reset process. You will receive an email with
|
||||
instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
@ -102,8 +103,10 @@ export const VerifyEmailPage = () => {
|
||||
Look for an email in your inbox.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max justify-center text-center text-sm text-gray-400">
|
||||
An email with instructions has been sent to {email}.
|
||||
<p className="w-max text-center text-sm text-gray-400">
|
||||
If the email is in our system, you will receive an email at{" "}
|
||||
<span className="italic">{email}</span> with instructions on how to reset your
|
||||
password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,7 +36,7 @@ const formSchema = z.object({
|
||||
revocationStatement: z.string().min(1),
|
||||
renewStatement: z.string().optional(),
|
||||
ca: z.string().optional(),
|
||||
projectGatewayId: z.string().optional()
|
||||
projectGatewayId: z.string().optional().nullable()
|
||||
})
|
||||
.partial(),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
@ -207,7 +207,7 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
helperText=""
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
|
@ -463,7 +463,7 @@ export const SecretDetailSidebar = ({
|
||||
<div
|
||||
className={`grid auto-cols-min grid-flow-col gap-2 overflow-hidden ${tagFields.fields.length > 0 ? "pt-2" : ""}`}
|
||||
>
|
||||
{tagFields.fields.map(({ tagColor, id: formId, slug, id }) => (
|
||||
{tagFields.fields.map(({ tagColor, id: formId, slug }) => (
|
||||
<Tag
|
||||
className="flex w-min items-center space-x-2"
|
||||
key={formId}
|
||||
@ -472,7 +472,8 @@ export const SecretDetailSidebar = ({
|
||||
createNotification({ type: "error", text: "Access denied" });
|
||||
return;
|
||||
}
|
||||
const tag = tags?.find(({ id: tagId }) => id === tagId);
|
||||
|
||||
const tag = tags?.find(({ slug: tagSlug }) => slug === tagSlug);
|
||||
if (tag) handleTagSelect(tag);
|
||||
}}
|
||||
>
|
||||
|
4
frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx
4
frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx
@ -241,8 +241,8 @@ export const SecretListView = ({
|
||||
|
||||
let successMessage;
|
||||
if (isReminderEvent) {
|
||||
successMessage = reminderRepeatDays
|
||||
? "Successfully saved secret reminder"
|
||||
successMessage = reminderRepeatDays
|
||||
? "Successfully saved secret reminder"
|
||||
: "Successfully deleted secret reminder";
|
||||
} else {
|
||||
successMessage = "Successfully saved secrets";
|
||||
|
@ -11,7 +11,9 @@ import attemptChangePassword from "@app/components/utilities/attemptChangePasswo
|
||||
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
|
||||
import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type Errors = {
|
||||
tooShort?: string;
|
||||
@ -35,6 +37,7 @@ export type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const ChangePasswordSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useUser();
|
||||
const { reset, control, handleSubmit } = useForm({
|
||||
@ -47,6 +50,7 @@ export const ChangePasswordSection = () => {
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const sendSetupPasswordEmail = useSendPasswordSetupEmail();
|
||||
const { mutateAsync: resetPasswordV2 } = useResetUserPasswordV2();
|
||||
|
||||
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
|
||||
try {
|
||||
@ -56,13 +60,20 @@ export const ChangePasswordSection = () => {
|
||||
});
|
||||
|
||||
if (errorCheck) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await attemptChangePassword({
|
||||
email: user.username,
|
||||
currentPassword: oldPassword,
|
||||
newPassword
|
||||
});
|
||||
|
||||
if (user.encryptionVersion === UserEncryptionVersion.V2) {
|
||||
await resetPasswordV2({
|
||||
oldPassword,
|
||||
newPassword
|
||||
});
|
||||
} else {
|
||||
await attemptChangePassword({
|
||||
email: user.username,
|
||||
currentPassword: oldPassword,
|
||||
newPassword
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
@ -71,7 +82,7 @@ export const ChangePasswordSection = () => {
|
||||
});
|
||||
|
||||
reset();
|
||||
window.location.href = "/login";
|
||||
navigate({ to: "/login" });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsLoading(false);
|
||||
|
@ -1,14 +1,20 @@
|
||||
import { useUser } from "@app/context";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
import { DeleteAccountSection } from "../DeleteAccountSection";
|
||||
import { EmergencyKitSection } from "../EmergencyKitSection";
|
||||
import { SessionsSection } from "../SessionsSection";
|
||||
import { UserNameSection } from "../UserNameSection";
|
||||
|
||||
export const PersonalGeneralTab = () => {
|
||||
const { user } = useUser();
|
||||
const encryptionVersion = user?.encryptionVersion ?? UserEncryptionVersion.V2;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserNameSection />
|
||||
<SessionsSection />
|
||||
<EmergencyKitSection />
|
||||
{encryptionVersion === UserEncryptionVersion.V1 && <EmergencyKitSection />}
|
||||
<DeleteAccountSection />
|
||||
</div>
|
||||
);
|
||||
|
Reference in New Issue
Block a user