mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-22 14:05:22 +00:00
Compare commits
138 Commits
daniel/vie
...
access-tre
Author | SHA1 | Date | |
---|---|---|---|
192dba04a5 | |||
0cc3240956 | |||
667580546b | |||
9fd662b7f7 | |||
dc30465afb | |||
f1caab2d00 | |||
1d186b1950 | |||
9cf5908cc1 | |||
f1b6c3764f | |||
4e6c860c69 | |||
eda9ed257e | |||
38cf43176e | |||
f5c7943f2f | |||
3c59f7f350 | |||
84cc7bcd6c | |||
159c27ac67 | |||
5f0dd31334 | |||
7e14c58931 | |||
c19016e6e6 | |||
20477ce2b0 | |||
e04b2220be | |||
edf6a37fe5 | |||
f5749e326a | |||
75e0a68b68 | |||
4dc56033b1 | |||
ed37b99756 | |||
6fa41a609b | |||
765be2d99d | |||
719a18c218 | |||
16d3bbb67a | |||
872a3fe48d | |||
c7414e00f9 | |||
ad1dd55b8b | |||
497761a0e5 | |||
483fb458dd | |||
b9b76579ac | |||
761965696b | |||
ace2500885 | |||
4eff7d8ea5 | |||
c4512ae111 | |||
78c349c09a | |||
09df440613 | |||
a8fc0e540a | |||
46ce46b5a0 | |||
dc88115d43 | |||
955657e172 | |||
f1ba64aa66 | |||
d74197aeb4 | |||
97567d06d4 | |||
3986df8e8a | |||
3fcd84b592 | |||
29e39b558b | |||
9458c8b04f | |||
3b95c5d859 | |||
de8f315211 | |||
9960d58e1b | |||
0057404562 | |||
47ca1b3011 | |||
716cd090c4 | |||
e870bb3ade | |||
98c9e98082 | |||
a814f459ab | |||
66817a40db | |||
20bd2ca71c | |||
004a8b71a2 | |||
f0fce3086e | |||
a9e7db6fc0 | |||
2bd681d58f | |||
51fef3ce60 | |||
df9e7bf6ee | |||
04479bb70a | |||
cdc90411e5 | |||
dcb05a3093 | |||
b055cda64d | |||
f68602280e | |||
f9483afe95 | |||
d742534f6a | |||
99eb8eb8ed | |||
1dea024880 | |||
699e03c1a9 | |||
f6372249b4 | |||
0f42fcd688 | |||
2e02f8bea8 | |||
8203158c63 | |||
ada04ed4fc | |||
cc9cc70125 | |||
045debeaf3 | |||
3fb8ad2fac | |||
795d9e4413 | |||
67f2e4671a | |||
cbe3acde74 | |||
de480b5771 | |||
07b93c5cec | |||
77431b4719 | |||
50610945be | |||
57f54440d6 | |||
9711e73a06 | |||
214f837041 | |||
58ebebb162 | |||
65ddddb6de | |||
a55b26164a | |||
6cd448b8a5 | |||
c48c9ae628 | |||
7003ad608a | |||
104edca6f1 | |||
75345d91c0 | |||
b7640f2d03 | |||
2ee4d68fd0 | |||
3ca931acf1 | |||
7f6715643d | |||
8e311658d4 | |||
9116acd37b | |||
0513307d98 | |||
28c2f1874e | |||
efc3b6d474 | |||
07e1d1b130 | |||
7f76779124 | |||
30bcf1f204 | |||
706feafbf2 | |||
fc4e3f1f72 | |||
dcd5f20325 | |||
58f3e116a3 | |||
7bc5aad8ec | |||
a16dc3aef6 | |||
da7746c639 | |||
cd5b6da541 | |||
2dda7180a9 | |||
30ccfbfc8e | |||
aa76924ee6 | |||
d8f679e72d | |||
bf6cfbac7a | |||
8e82813894 | |||
c54eafc128 | |||
757942aefc | |||
1d57629036 | |||
8061066e27 | |||
c993b1bbe3 | |||
2cbf33ac14 |
.github/workflows
backend
e2e-test/routes/v2
package.jsonsrc
db
migrations
20250218020306_backfill-secret-permissions-with-readvalue.ts20250305131152_add-actor-id-to-secret-versions-v2.ts
schemas
ee
routes
v1
v2
services
dynamic-secret/providers
group
identity-project-additional-privilege-v2
identity-project-additional-privilege
permission
project-user-additional-privilege
secret-approval-request
secret-rotation/secret-rotation-queue
secret-snapshot
lib
api-docs
casl
errors
gateway
turn
queue
server
plugins
routes
services
auth
external-migration
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
integration-auth
integration
project-membership
project
secret-folder
secret-import
secret-sync
secret-v2-bridge
secret-v2-bridge-dal.tssecret-v2-bridge-fns.tssecret-v2-bridge-service.tssecret-v2-bridge-types.tssecret-version-dal.ts
secret
service-token
slack
super-admin
user
webhook
cli
company/handbook
docs
cli/commands
documentation/platform
images/platform/secret-scanning
integrations
mint.jsonfrontend
package-lock.jsonpackage.json
src
components
auth
permissions
project
projects
secret-syncs/forms/SecretSyncDestinationFields
utilities/checks/password
v2/Select
context/ProjectPermissionContext
helpers
hooks
layouts/OrganizationLayout/components
MenuIconButton
MinimizedOrgSidebar
ServerAdminsPanel
lib/fn
main.tsxpages
admin
OverviewPage/components
SignUpPage
auth
cert-manager/SettingsPage/components
kms/SettingsPage/components
organization/AuditLogsPage/components
project
AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection
RoleDetailsBySlugPage/components
public/ShareSecretPage/components
secret-manager
OverviewPage
OverviewPage.tsx
components
SecretDashboardPage
SecretDashboardPage.tsx
components
ActionBar
DynamicSecretListView/EditDynamicSecretForm
FolderListView
PitDrawer
SecretDropzone
SecretListView
SettingsPage/components
integrations/DatabricksConfigurePage
ssh/SettingsPage/components
user/PersonalSettingsPage/components
@ -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
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { decryptAsymmetric, decryptSymmetric128BitHexKeyUTF8, encryptSymmetric12
|
||||
|
||||
const createServiceToken = async (
|
||||
scopes: { environment: string; secretPath: string }[],
|
||||
permissions: ("read" | "write" | "readValue")[]
|
||||
permissions: ("read" | "write")[]
|
||||
) => {
|
||||
const projectKeyRes = await testServer.inject({
|
||||
method: "GET",
|
||||
@ -139,7 +139,7 @@ describe("Service token secret ops", async () => {
|
||||
beforeAll(async () => {
|
||||
serviceToken = await createServiceToken(
|
||||
[{ secretPath: "/**", environment: seedData1.environment.slug }],
|
||||
["read", "write", "readValue"]
|
||||
["read", "write"]
|
||||
);
|
||||
|
||||
// this is ensure cli service token decryptiong working fine
|
||||
@ -496,7 +496,7 @@ describe("Service token fail cases", async () => {
|
||||
test("Unauthorized secret path access", async () => {
|
||||
const serviceToken = await createServiceToken(
|
||||
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||
["read", "readValue", "write"]
|
||||
["read", "write"]
|
||||
);
|
||||
const fetchSecrets = await testServer.inject({
|
||||
method: "GET",
|
||||
@ -518,7 +518,7 @@ describe("Service token fail cases", async () => {
|
||||
test("Unauthorized secret environment access", async () => {
|
||||
const serviceToken = await createServiceToken(
|
||||
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||
["read", "readValue", "write"]
|
||||
["read", "write"]
|
||||
);
|
||||
const fetchSecrets = await testServer.inject({
|
||||
method: "GET",
|
||||
@ -540,7 +540,7 @@ describe("Service token fail cases", async () => {
|
||||
test("Unauthorized write operation", async () => {
|
||||
const serviceToken = await createServiceToken(
|
||||
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||
["read", "readValue"]
|
||||
["read"]
|
||||
);
|
||||
const writeSecrets = await testServer.inject({
|
||||
method: "POST",
|
||||
|
@ -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",
|
||||
@ -70,6 +71,7 @@
|
||||
"migrate:org": "tsx ./scripts/migrate-organization.ts",
|
||||
"seed:new": "tsx ./scripts/create-seed-file.ts",
|
||||
"seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run",
|
||||
"seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run",
|
||||
"db:reset": "npm run migration:rollback -- --all && npm run migration:latest"
|
||||
},
|
||||
"keywords": [],
|
||||
|
@ -1,313 +0,0 @@
|
||||
import { MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
import { selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
enum ProjectPermissionSub {
|
||||
Secrets = "secrets"
|
||||
}
|
||||
|
||||
enum SecretActions {
|
||||
Read = "read",
|
||||
ReadValue = "readValue"
|
||||
}
|
||||
|
||||
const UnpackedPermissionSchema = z.object({
|
||||
subject: z
|
||||
.union([z.string().min(1), z.string().array()])
|
||||
.transform((el) => (typeof el !== "string" ? el[0] : el))
|
||||
.optional(),
|
||||
action: z.union([z.string().min(1), z.string().array()]).transform((el) => (typeof el === "string" ? [el] : el)),
|
||||
conditions: z.unknown().optional(),
|
||||
inverted: z.boolean().optional()
|
||||
});
|
||||
|
||||
const $unpackPermissions = (permissions: unknown) =>
|
||||
UnpackedPermissionSchema.array().parse(unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility>>[]));
|
||||
|
||||
const $updatePermissionsUp = (permissions: unknown) => {
|
||||
const parsedPermissions = $unpackPermissions(permissions);
|
||||
let shouldUpdate = false;
|
||||
|
||||
for (let i = 0; i < parsedPermissions.length; i += 1) {
|
||||
const parsedPermission = parsedPermissions[i];
|
||||
const { subject, action } = parsedPermission;
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
if (action.includes(SecretActions.Read) && !action.includes(SecretActions.ReadValue)) {
|
||||
action.push(SecretActions.ReadValue);
|
||||
parsedPermissions[i] = { ...parsedPermission, action };
|
||||
shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parsedPermissions,
|
||||
shouldUpdate
|
||||
};
|
||||
};
|
||||
|
||||
const $updatePermissionsDown = (permissions: unknown) => {
|
||||
const parsedPermissions = $unpackPermissions(permissions);
|
||||
|
||||
let shouldUpdate = false;
|
||||
for (let i = 0; i < parsedPermissions.length; i += 1) {
|
||||
const parsedPermission = parsedPermissions[i];
|
||||
|
||||
const { subject, action } = parsedPermission;
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
const readValueIndex = action.indexOf(SecretActions.ReadValue);
|
||||
|
||||
if (action.includes(SecretActions.ReadValue) && readValueIndex !== -1) {
|
||||
action.splice(readValueIndex, 1);
|
||||
parsedPermissions[i] = { ...parsedPermission, action };
|
||||
|
||||
shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repackedPermissions = packRules(parsedPermissions);
|
||||
|
||||
return {
|
||||
repackedPermissions,
|
||||
shouldUpdate
|
||||
};
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 1000;
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const projectRoles = await knex(TableName.ProjectRoles).select(selectAllTableCols(TableName.ProjectRoles));
|
||||
const projectIdentityAdditionalPrivileges = await knex(TableName.IdentityProjectAdditionalPrivilege).select(
|
||||
selectAllTableCols(TableName.IdentityProjectAdditionalPrivilege)
|
||||
);
|
||||
const projectUserAdditionalPrivileges = await knex(TableName.ProjectUserAdditionalPrivilege).select(
|
||||
selectAllTableCols(TableName.ProjectUserAdditionalPrivilege)
|
||||
);
|
||||
|
||||
const serviceTokens = await knex(TableName.ServiceToken).select(selectAllTableCols(TableName.ServiceToken));
|
||||
|
||||
const updatedServiceTokens = serviceTokens.reduce<typeof serviceTokens>((acc, serviceToken) => {
|
||||
const { permissions } = serviceToken; // Service tokens are special, and include an array of actions only.
|
||||
|
||||
if (permissions.includes(SecretActions.Read) && !permissions.includes(SecretActions.ReadValue)) {
|
||||
permissions.push(SecretActions.ReadValue);
|
||||
acc.push({
|
||||
...serviceToken,
|
||||
permissions
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (updatedServiceTokens.length > 0) {
|
||||
for (let i = 0; i < updatedServiceTokens.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedServiceTokens.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ServiceToken)
|
||||
.whereIn(
|
||||
"id",
|
||||
chunk.map((t) => t.id)
|
||||
)
|
||||
.update({
|
||||
// @ts-expect-error -- raw query
|
||||
permissions: knex.raw(
|
||||
`CASE id
|
||||
${chunk.map((t) => `WHEN '${t.id}' THEN ?::text[]`).join(" ")}
|
||||
END`,
|
||||
chunk.map((t) => t.permissions)
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRoles = projectRoles.reduce<typeof projectRoles>((acc, projectRole) => {
|
||||
const { shouldUpdate, parsedPermissions } = $updatePermissionsUp(projectRole.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...projectRole,
|
||||
permissions: JSON.stringify(packRules(parsedPermissions))
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const updatedIdentityAdditionalPrivileges = projectIdentityAdditionalPrivileges.reduce<
|
||||
typeof projectIdentityAdditionalPrivileges
|
||||
>((acc, identityAdditionalPrivilege) => {
|
||||
const { shouldUpdate, parsedPermissions } = $updatePermissionsUp(identityAdditionalPrivilege.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...identityAdditionalPrivilege,
|
||||
permissions: JSON.stringify(packRules(parsedPermissions))
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const updatedUserAdditionalPrivileges = projectUserAdditionalPrivileges.reduce<
|
||||
typeof projectUserAdditionalPrivileges
|
||||
>((acc, userAdditionalPrivilege) => {
|
||||
const { shouldUpdate, parsedPermissions } = $updatePermissionsUp(userAdditionalPrivilege.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...userAdditionalPrivilege,
|
||||
permissions: JSON.stringify(packRules(parsedPermissions))
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (updatedRoles.length > 0) {
|
||||
for (let i = 0; i < updatedRoles.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedRoles.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ProjectRoles).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedIdentityAdditionalPrivileges.length > 0) {
|
||||
for (let i = 0; i < updatedIdentityAdditionalPrivileges.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedIdentityAdditionalPrivileges.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.IdentityProjectAdditionalPrivilege).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedUserAdditionalPrivileges.length > 0) {
|
||||
for (let i = 0; i < updatedUserAdditionalPrivileges.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedUserAdditionalPrivileges.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ProjectUserAdditionalPrivilege).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const projectRoles = await knex(TableName.ProjectRoles).select(selectAllTableCols(TableName.ProjectRoles));
|
||||
const identityAdditionalPrivileges = await knex(TableName.IdentityProjectAdditionalPrivilege).select(
|
||||
selectAllTableCols(TableName.IdentityProjectAdditionalPrivilege)
|
||||
);
|
||||
const userAdditionalPrivileges = await knex(TableName.ProjectUserAdditionalPrivilege).select(
|
||||
selectAllTableCols(TableName.ProjectUserAdditionalPrivilege)
|
||||
);
|
||||
const serviceTokens = await knex(TableName.ServiceToken).select(selectAllTableCols(TableName.ServiceToken));
|
||||
|
||||
const updatedServiceTokens = serviceTokens.reduce<typeof serviceTokens>((acc, serviceToken) => {
|
||||
const { permissions } = serviceToken;
|
||||
|
||||
if (permissions.includes(SecretActions.ReadValue)) {
|
||||
permissions.splice(permissions.indexOf(SecretActions.ReadValue), 1);
|
||||
acc.push({
|
||||
...serviceToken,
|
||||
permissions
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (updatedServiceTokens.length > 0) {
|
||||
for (let i = 0; i < updatedServiceTokens.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedServiceTokens.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ServiceToken)
|
||||
.whereIn(
|
||||
"id",
|
||||
chunk.map((t) => t.id)
|
||||
)
|
||||
.update({
|
||||
// @ts-expect-error -- raw query
|
||||
permissions: knex.raw(
|
||||
`CASE id
|
||||
${chunk.map((t) => `WHEN '${t.id}' THEN ?::text[]`).join(" ")}
|
||||
END`,
|
||||
chunk.map((t) => t.permissions)
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRoles = projectRoles.reduce<typeof projectRoles>((acc, projectRole) => {
|
||||
const { shouldUpdate, repackedPermissions } = $updatePermissionsDown(projectRole.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...projectRole,
|
||||
permissions: JSON.stringify(repackedPermissions)
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const updatedIdentityAdditionalPrivileges = identityAdditionalPrivileges.reduce<typeof identityAdditionalPrivileges>(
|
||||
(acc, identityAdditionalPrivilege) => {
|
||||
const { shouldUpdate, repackedPermissions } = $updatePermissionsDown(identityAdditionalPrivilege.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...identityAdditionalPrivilege,
|
||||
permissions: JSON.stringify(repackedPermissions)
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updatedUserAdditionalPrivileges = userAdditionalPrivileges.reduce<typeof userAdditionalPrivileges>(
|
||||
(acc, userAdditionalPrivilege) => {
|
||||
const { shouldUpdate, repackedPermissions } = $updatePermissionsDown(userAdditionalPrivilege.permissions);
|
||||
|
||||
if (shouldUpdate) {
|
||||
acc.push({
|
||||
...userAdditionalPrivilege,
|
||||
permissions: JSON.stringify(repackedPermissions)
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (updatedRoles.length > 0) {
|
||||
for (let i = 0; i < updatedRoles.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedRoles.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ProjectRoles).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedIdentityAdditionalPrivileges.length > 0) {
|
||||
for (let i = 0; i < updatedIdentityAdditionalPrivileges.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedIdentityAdditionalPrivileges.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.IdentityProjectAdditionalPrivilege).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedUserAdditionalPrivileges.length > 0) {
|
||||
for (let i = 0; i < updatedUserAdditionalPrivileges.length; i += CHUNK_SIZE) {
|
||||
const chunk = updatedUserAdditionalPrivileges.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(TableName.ProjectUserAdditionalPrivilege).insert(chunk).onConflict("id").merge(["permissions"]);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretVersionV2)) {
|
||||
const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId");
|
||||
const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId");
|
||||
const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretVersionV2, (t) => {
|
||||
if (!hasSecretVersionV2UserActorId) {
|
||||
t.uuid("userActorId");
|
||||
t.foreign("userActorId").references("id").inTable(TableName.Users);
|
||||
}
|
||||
if (!hasSecretVersionV2IdentityActorId) {
|
||||
t.uuid("identityActorId");
|
||||
t.foreign("identityActorId").references("id").inTable(TableName.Identity);
|
||||
}
|
||||
if (!hasSecretVersionV2ActorType) {
|
||||
t.string("actorType");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretVersionV2)) {
|
||||
const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId");
|
||||
const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId");
|
||||
const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretVersionV2, (t) => {
|
||||
if (hasSecretVersionV2UserActorId) {
|
||||
t.dropColumn("userActorId");
|
||||
}
|
||||
if (hasSecretVersionV2IdentityActorId) {
|
||||
t.dropColumn("identityActorId");
|
||||
}
|
||||
if (hasSecretVersionV2ActorType) {
|
||||
t.dropColumn("actorType");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -25,7 +25,10 @@ export const SecretVersionsV2Schema = z.object({
|
||||
folderId: z.string().uuid(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
userActorId: z.string().uuid().nullable().optional(),
|
||||
identityActorId: z.string().uuid().nullable().optional(),
|
||||
actorType: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretVersionsV2 = z.infer<typeof SecretVersionsV2Schema>;
|
||||
|
@ -2,6 +2,7 @@ import slugify from "@sindresorhus/slugify";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
|
||||
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
|
||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types";
|
||||
import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
|
||||
@ -23,7 +24,9 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
|
||||
body: z.object({
|
||||
projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId),
|
||||
slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions),
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
.refine(checkForInvalidPermissionCombination),
|
||||
type: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
@ -81,7 +84,8 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
|
||||
slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug),
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.optional()
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
|
||||
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions)
|
||||
.refine(checkForInvalidPermissionCombination),
|
||||
type: z.discriminatedUnion("isTemporary", [
|
||||
z.object({ isTemporary: z.literal(false).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary) }),
|
||||
z.object({
|
||||
|
@ -3,6 +3,7 @@ import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types";
|
||||
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
|
||||
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
|
||||
import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
@ -30,7 +31,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId),
|
||||
projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId),
|
||||
slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug),
|
||||
permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission),
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission)
|
||||
.refine(checkForInvalidPermissionCombination),
|
||||
type: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
@ -94,7 +97,8 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug),
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.optional()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission),
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission)
|
||||
.refine(checkForInvalidPermissionCombination),
|
||||
type: z.discriminatedUnion("isTemporary", [
|
||||
z.object({ isTemporary: z.literal(false).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.isTemporary) }),
|
||||
z.object({
|
||||
|
@ -2,6 +2,7 @@ import { packRules } from "@casl/ability/extra";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
|
||||
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
|
||||
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
|
||||
import { PROJECT_ROLE } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
@ -37,7 +38,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
||||
.describe(PROJECT_ROLE.CREATE.slug),
|
||||
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
|
||||
description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description),
|
||||
permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.CREATE.permissions)
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.describe(PROJECT_ROLE.CREATE.permissions)
|
||||
.refine(checkForInvalidPermissionCombination)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -92,7 +95,10 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
||||
.describe(PROJECT_ROLE.UPDATE.slug),
|
||||
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
|
||||
description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description),
|
||||
permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
|
||||
permissions: ProjectPermissionV2Schema.array()
|
||||
.describe(PROJECT_ROLE.UPDATE.permissions)
|
||||
.optional()
|
||||
.superRefine(checkForInvalidPermissionCombination)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -1,5 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type PasswordRequirements = {
|
||||
length: number;
|
||||
required: {
|
||||
lowercase: number;
|
||||
uppercase: number;
|
||||
digits: number;
|
||||
symbols: number;
|
||||
};
|
||||
allowedSymbols?: string;
|
||||
};
|
||||
|
||||
export enum SqlProviders {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql2",
|
||||
@ -100,6 +111,28 @@ export const DynamicSecretSqlDBSchema = z.object({
|
||||
database: z.string().trim(),
|
||||
username: z.string().trim(),
|
||||
password: z.string().trim(),
|
||||
passwordRequirements: z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250;
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length")
|
||||
.optional()
|
||||
.describe("Password generation requirements"),
|
||||
creationStatement: z.string().trim(),
|
||||
revocationStatement: z.string().trim(),
|
||||
renewStatement: z.string().trim().optional(),
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { randomInt } from "crypto";
|
||||
import handlebars from "handlebars";
|
||||
import knex from "knex";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withGatewayProxy } from "@app/lib/gateway";
|
||||
@ -8,16 +8,99 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
|
||||
import { verifyHostInputValidity } from "../dynamic-secret-fns";
|
||||
import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models";
|
||||
import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
const generatePassword = (provider: SqlProviders) => {
|
||||
// oracle has limit of 48 password length
|
||||
const size = provider === SqlProviders.Oracle ? 30 : 48;
|
||||
const DEFAULT_PASSWORD_REQUIREMENTS = {
|
||||
length: 48,
|
||||
required: {
|
||||
lowercase: 1,
|
||||
uppercase: 1,
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: "-_.~!*"
|
||||
};
|
||||
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
const ORACLE_PASSWORD_REQUIREMENTS = {
|
||||
...DEFAULT_PASSWORD_REQUIREMENTS,
|
||||
length: 30
|
||||
};
|
||||
|
||||
const generatePassword = (provider: SqlProviders, requirements?: PasswordRequirements) => {
|
||||
const defaultReqs = provider === SqlProviders.Oracle ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS;
|
||||
const finalReqs = requirements || defaultReqs;
|
||||
|
||||
try {
|
||||
const { length, required, allowedSymbols } = finalReqs;
|
||||
|
||||
const chars = {
|
||||
lowercase: "abcdefghijklmnopqrstuvwxyz",
|
||||
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
digits: "0123456789",
|
||||
symbols: allowedSymbols || "-_.~!*"
|
||||
};
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (required.lowercase > 0) {
|
||||
parts.push(
|
||||
...Array(required.lowercase)
|
||||
.fill(0)
|
||||
.map(() => chars.lowercase[randomInt(chars.lowercase.length)])
|
||||
);
|
||||
}
|
||||
|
||||
if (required.uppercase > 0) {
|
||||
parts.push(
|
||||
...Array(required.uppercase)
|
||||
.fill(0)
|
||||
.map(() => chars.uppercase[randomInt(chars.uppercase.length)])
|
||||
);
|
||||
}
|
||||
|
||||
if (required.digits > 0) {
|
||||
parts.push(
|
||||
...Array(required.digits)
|
||||
.fill(0)
|
||||
.map(() => chars.digits[randomInt(chars.digits.length)])
|
||||
);
|
||||
}
|
||||
|
||||
if (required.symbols > 0) {
|
||||
parts.push(
|
||||
...Array(required.symbols)
|
||||
.fill(0)
|
||||
.map(() => chars.symbols[randomInt(chars.symbols.length)])
|
||||
);
|
||||
}
|
||||
|
||||
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
|
||||
const remainingLength = Math.max(length - requiredTotal, 0);
|
||||
|
||||
const allowedChars = Object.entries(chars)
|
||||
.filter(([key]) => required[key as keyof typeof required] > 0)
|
||||
.map(([, value]) => value)
|
||||
.join("");
|
||||
|
||||
parts.push(
|
||||
...Array(remainingLength)
|
||||
.fill(0)
|
||||
.map(() => allowedChars[randomInt(allowedChars.length)])
|
||||
);
|
||||
|
||||
// shuffle the array to mix up the characters
|
||||
for (let i = parts.length - 1; i > 0; i -= 1) {
|
||||
const j = randomInt(i + 1);
|
||||
[parts[i], parts[j]] = [parts[j], parts[i]];
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(`Failed to generate password: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const generateUsername = (provider: SqlProviders) => {
|
||||
@ -115,7 +198,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
|
||||
const create = async (inputs: unknown, expireAt: number) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const username = generateUsername(providerInputs.client);
|
||||
const password = generatePassword(providerInputs.client);
|
||||
const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements);
|
||||
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
|
||||
const db = await $getClient({ ...providerInputs, port, host });
|
||||
try {
|
||||
|
@ -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,
|
||||
|
@ -1,7 +1,109 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TOrganizations } from "@app/db/schemas";
|
||||
import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type";
|
||||
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub,
|
||||
ProjectPermissionV2Schema,
|
||||
SecretSubjectFields
|
||||
} from "./project-permission";
|
||||
|
||||
export function throwIfMissingSecretReadValueOrDescribePermission(
|
||||
permission: MongoAbility<ProjectPermissionSet> | PureAbility,
|
||||
action: Extract<
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretActions.ReadValue | ProjectPermissionSecretActions.DescribeSecret
|
||||
>,
|
||||
subjectFields?: SecretSubjectFields
|
||||
) {
|
||||
try {
|
||||
if (subjectFields) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, subjectFields)
|
||||
);
|
||||
} else {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (subjectFields) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(action, subject(ProjectPermissionSub.Secrets, subjectFields));
|
||||
} else {
|
||||
ForbiddenError.from(permission).throwUnlessCan(action, ProjectPermissionSub.Secrets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSecretReadValueOrDescribePermission(
|
||||
permission: MongoAbility<ProjectPermissionSet>,
|
||||
action: Extract<
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue
|
||||
>,
|
||||
subjectFields?: SecretSubjectFields
|
||||
) {
|
||||
let canNewPermission = false;
|
||||
let canOldPermission = false;
|
||||
|
||||
if (subjectFields) {
|
||||
canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields));
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, subjectFields)
|
||||
);
|
||||
} else {
|
||||
canNewPermission = permission.can(action, ProjectPermissionSub.Secrets);
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
}
|
||||
|
||||
return canNewPermission || canOldPermission;
|
||||
}
|
||||
|
||||
const OptionalArrayPermissionSchema = ProjectPermissionV2Schema.array().optional();
|
||||
export function checkForInvalidPermissionCombination(permissions: z.infer<typeof OptionalArrayPermissionSchema>) {
|
||||
if (!permissions) return;
|
||||
|
||||
for (const permission of permissions) {
|
||||
if (permission.subject === ProjectPermissionSub.Secrets) {
|
||||
if (permission.action.includes(ProjectPermissionSecretActions.DescribeAndReadValue)) {
|
||||
const hasReadValue = permission.action.includes(ProjectPermissionSecretActions.ReadValue);
|
||||
const hasDescribeSecret = permission.action.includes(ProjectPermissionSecretActions.DescribeSecret);
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!hasReadValue && !hasDescribeSecret) continue;
|
||||
|
||||
const hasBothDescribeAndReadValue = hasReadValue && hasDescribeSecret;
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `You have selected Read, and ${
|
||||
hasBothDescribeAndReadValue
|
||||
? "both Read Value and Describe Secret"
|
||||
: hasReadValue
|
||||
? "Read Value"
|
||||
: hasDescribeSecret
|
||||
? "Describe Secret"
|
||||
: ""
|
||||
}. You cannot select Read Value or Describe Secret if you have selected Read. The Read permission is a legacy action which has been replaced by Describe Secret and Read Value.`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
|
||||
if (!actorAuthMethod) return false;
|
||||
|
||||
|
@ -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
|
||||
|
@ -18,7 +18,8 @@ export enum ProjectPermissionActions {
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretActions {
|
||||
DescribeSecret = "read",
|
||||
DescribeAndReadValue = "read",
|
||||
DescribeSecret = "describeSecret",
|
||||
ReadValue = "readValue",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
@ -564,6 +565,7 @@ const buildAdminPermissionRules = () => {
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
@ -632,6 +634,7 @@ const buildMemberPermissionRules = () => {
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
@ -808,6 +811,7 @@ export const projectMemberPermissions = buildMemberPermissionRules();
|
||||
const buildViewerPermissionRules = () => {
|
||||
const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
|
||||
|
||||
can(ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSub.Secrets);
|
||||
can(ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSub.Secrets);
|
||||
can(ProjectPermissionSecretActions.ReadValue, ProjectPermissionSub.Secrets);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders);
|
||||
@ -852,15 +856,12 @@ export const buildServiceTokenProjectPermission = (
|
||||
) => {
|
||||
const canWrite = permission.includes("write");
|
||||
const canRead = permission.includes("read");
|
||||
const canReadValue = permission.includes("readValue");
|
||||
|
||||
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
|
||||
scopes.forEach(({ secretPath, environment }) => {
|
||||
[ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretFolders].forEach(
|
||||
(subject) => {
|
||||
if (canWrite) {
|
||||
can(ProjectPermissionActions.Edit, subject, {
|
||||
// TODO: @Akhi
|
||||
// @ts-expect-error type
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
@ -883,14 +884,6 @@ export const buildServiceTokenProjectPermission = (
|
||||
environment
|
||||
});
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets && canReadValue) {
|
||||
// @ts-expect-error type
|
||||
can(ProjectPermissionSecretActions.ReadValue, subject as ProjectPermissionSub.Secrets, {
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -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({
|
||||
|
@ -57,6 +57,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
|
||||
@ -508,7 +509,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
if (!hasMinApproval && !isSoftEnforcement)
|
||||
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
|
||||
|
||||
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId);
|
||||
let mergeStatus;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
// this cycle if for bridged secrets
|
||||
@ -866,7 +867,6 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
|
||||
if (isSoftEnforcement) {
|
||||
const cfg = getConfig();
|
||||
const project = await projectDAL.findProjectById(projectId);
|
||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||
const requestedByUser = await userDAL.findOne({ id: actorId });
|
||||
const approverUsers = await userDAL.find({
|
||||
@ -918,10 +918,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||
|
||||
@ -1162,7 +1163,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
environment: env.name,
|
||||
secretPath,
|
||||
projectId,
|
||||
requestId: secretApprovalRequest.id
|
||||
requestId: secretApprovalRequest.id,
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -1462,7 +1464,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
environment: env.name,
|
||||
secretPath,
|
||||
projectId,
|
||||
requestId: secretApprovalRequest.id
|
||||
requestId: secretApprovalRequest.id,
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -13,6 +13,7 @@ import { NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@ -332,6 +333,7 @@ export const secretRotationQueueFactory = ({
|
||||
await secretVersionV2BridgeDAL.insertMany(
|
||||
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
|
||||
...el,
|
||||
actorType: ActorType.PLATFORM,
|
||||
secretId: id
|
||||
})),
|
||||
tx
|
||||
|
@ -1,16 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument */
|
||||
// akhilmhdh: I did this, quite strange bug with eslint. Everything do have a type stil has this error
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TableName, TSecretTagJunctionInsert, TSecretV2TagJunctionInsert } from "@app/db/schemas";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "@app/services/secret/secret-fns";
|
||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
@ -21,6 +23,10 @@ import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secre
|
||||
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import {
|
||||
hasSecretReadValueOrDescribePermission,
|
||||
throwIfMissingSecretReadValueOrDescribePermission
|
||||
} from "../permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
@ -38,7 +44,6 @@ import { TSnapshotFolderDALFactory } from "./snapshot-folder-dal";
|
||||
import { TSnapshotSecretDALFactory } from "./snapshot-secret-dal";
|
||||
import { TSnapshotSecretV2DALFactory } from "./snapshot-secret-v2-dal";
|
||||
import { getFullFolderPath } from "./snapshot-service-fns";
|
||||
import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "@app/services/secret/secret-fns";
|
||||
|
||||
type TSecretSnapshotServiceFactoryDep = {
|
||||
snapshotDAL: TSnapshotDALFactory;
|
||||
@ -101,10 +106,10 @@ export const secretSnapshotServiceFactory = ({
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
|
||||
|
||||
// We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder.
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath: path
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) {
|
||||
@ -138,10 +143,10 @@ export const secretSnapshotServiceFactory = ({
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
|
||||
|
||||
// We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder.
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath: path
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder)
|
||||
@ -185,14 +190,15 @@ export const secretSnapshotServiceFactory = ({
|
||||
snapshotDetails = {
|
||||
...encryptedSnapshotDetails,
|
||||
secretVersions: encryptedSnapshotDetails.secretVersions.map((el) => {
|
||||
const canReadValue = permission.can(
|
||||
const canReadValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: encryptedSnapshotDetails.environment.slug,
|
||||
secretPath: fullFolderPath,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.length ? el.tags.map((tag) => tag.slug) : undefined
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
let secretValue = "";
|
||||
@ -237,14 +243,15 @@ export const secretSnapshotServiceFactory = ({
|
||||
key: botKey
|
||||
});
|
||||
|
||||
const canReadValue = permission.can(
|
||||
const canReadValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: encryptedSnapshotDetails.environment.slug,
|
||||
secretPath: fullFolderPath,
|
||||
secretName: secretKey,
|
||||
secretTags: el.tags.length ? el.tags.map((tag) => tag.slug) : undefined
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
let secretValue = "";
|
||||
@ -418,7 +425,21 @@ export const secretSnapshotServiceFactory = ({
|
||||
const secrets = await secretV2BridgeDAL.insertMany(
|
||||
rollbackSnaps.flatMap(({ secretVersions, folderId }) =>
|
||||
secretVersions.map(
|
||||
({ latestSecretVersion, version, updatedAt, createdAt, secretId, envId, id, tags, ...el }) => ({
|
||||
({
|
||||
latestSecretVersion,
|
||||
version,
|
||||
updatedAt,
|
||||
createdAt,
|
||||
secretId,
|
||||
envId,
|
||||
id,
|
||||
tags,
|
||||
// exclude the bottom fields from the secret - they are for versioning only.
|
||||
userActorId,
|
||||
identityActorId,
|
||||
actorType,
|
||||
...el
|
||||
}) => ({
|
||||
...el,
|
||||
id: secretId,
|
||||
version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion,
|
||||
@ -449,8 +470,18 @@ export const secretSnapshotServiceFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
const userActorId = actor === ActorType.USER ? actorId : undefined;
|
||||
const identityActorId = actor !== ActorType.USER ? actorId : undefined;
|
||||
const actorType = actor || ActorType.PLATFORM;
|
||||
|
||||
const secretVersions = await secretVersionV2BridgeDAL.insertMany(
|
||||
secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, secretId: id })),
|
||||
secrets.map(({ id, updatedAt, createdAt, ...el }) => ({
|
||||
...el,
|
||||
secretId: id,
|
||||
userActorId,
|
||||
identityActorId,
|
||||
actorType
|
||||
})),
|
||||
tx
|
||||
);
|
||||
await secretVersionV2TagBridgeDAL.insertMany(
|
||||
|
@ -459,7 +459,8 @@ export const PROJECTS = {
|
||||
workspaceId: "The ID of the project to update.",
|
||||
name: "The new name of the project.",
|
||||
projectDescription: "An optional description label for the project.",
|
||||
autoCapitalization: "Disable or enable auto-capitalization for the project."
|
||||
autoCapitalization: "Disable or enable auto-capitalization for the project.",
|
||||
slug: "An optional slug for the project. (must be unique within the organization)"
|
||||
},
|
||||
GET_KEY: {
|
||||
workspaceId: "The ID of the project to get the key from."
|
||||
|
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"
|
||||
|
@ -53,10 +53,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import * as quic from "@infisical/quic";
|
||||
import quicDefault, * as quicModule from "@infisical/quic";
|
||||
|
||||
import { BadRequestError } from "../errors";
|
||||
import { logger } from "../logger";
|
||||
@ -10,6 +10,8 @@ import { logger } from "../logger";
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const DEFAULT_RETRY_DELAY = 1000; // 1 second
|
||||
|
||||
const quic = quicDefault || quicModule;
|
||||
|
||||
const parseSubjectDetails = (data: string) => {
|
||||
const values: Record<string, string> = {};
|
||||
data.split("\n").forEach((el) => {
|
||||
@ -94,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");
|
||||
@ -106,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();
|
||||
@ -144,6 +143,7 @@ interface TProxyServer {
|
||||
server: net.Server;
|
||||
port: number;
|
||||
cleanup: () => Promise<void>;
|
||||
getProxyError: () => string;
|
||||
}
|
||||
|
||||
const setupProxyServer = async ({
|
||||
@ -168,6 +168,7 @@ const setupProxyServer = async ({
|
||||
error: err as Error
|
||||
});
|
||||
});
|
||||
const proxyErrorMsg = [""];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@ -183,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
|
||||
@ -236,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 () => {
|
||||
@ -252,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();
|
||||
@ -270,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") {
|
||||
@ -291,7 +302,8 @@ const setupProxyServer = async ({
|
||||
cleanup: async () => {
|
||||
server.close();
|
||||
await quicClient?.destroy();
|
||||
}
|
||||
},
|
||||
getProxyError: () => proxyErrorMsg.join(",")
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -314,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,
|
||||
@ -328,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}`;
|
||||
|
@ -83,6 +83,14 @@ const run = async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error(error, "CRITICAL ERROR: Uncaught Exception");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (error) => {
|
||||
logger.error(error, "CRITICAL ERROR: Unhandled Promise Rejection");
|
||||
});
|
||||
|
||||
await server.listen({
|
||||
port: envConfig.PORT,
|
||||
host: envConfig.HOST,
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
TQueueSecretSyncSyncSecretsByIdDTO,
|
||||
TQueueSendSecretSyncActionFailedNotificationsDTO
|
||||
} from "@app/services/secret-sync/secret-sync-types";
|
||||
import { TWebhookPayloads } from "@app/services/webhook/webhook-types";
|
||||
|
||||
export enum QueueName {
|
||||
SecretRotation = "secret-rotation",
|
||||
@ -107,7 +108,7 @@ export type TQueueJobTypes = {
|
||||
};
|
||||
[QueueName.SecretWebhook]: {
|
||||
name: QueueJobs.SecWebhook;
|
||||
payload: { projectId: string; environment: string; secretPath: string; depth?: number };
|
||||
payload: TWebhookPayloads;
|
||||
};
|
||||
|
||||
[QueueName.AccessTokenStatusUpdate]:
|
||||
|
@ -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({
|
||||
|
@ -112,7 +112,16 @@ export const secretRawSchema = z.object({
|
||||
secretReminderRepeatDays: z.number().nullable().optional(),
|
||||
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
actor: z
|
||||
.object({
|
||||
actorId: z.string().nullable().optional(),
|
||||
actorType: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
membershipId: z.string().nullable().optional()
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
});
|
||||
|
||||
export const ProjectPermissionSchema = z.object({
|
||||
|
@ -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({
|
||||
|
@ -834,6 +834,52 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/accessible-secrets",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
filterByAction: z
|
||||
.enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue])
|
||||
.default(ProjectPermissionSecretActions.ReadValue)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secrets: secretRawSchema
|
||||
.extend({
|
||||
secretPath: z.string().optional(),
|
||||
secretValueHidden: z.boolean()
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { projectId, environment, secretPath, filterByAction } = req.query;
|
||||
|
||||
const { secrets } = await server.services.secret.getAccessibleSecrets({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
environment,
|
||||
secretPath,
|
||||
projectId,
|
||||
filterByAction
|
||||
});
|
||||
|
||||
return { secrets };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/secrets-by-keys",
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -2,10 +2,12 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
IntegrationsSchema,
|
||||
ProjectEnvironmentsSchema,
|
||||
ProjectMembershipsSchema,
|
||||
ProjectRolesSchema,
|
||||
ProjectSlackConfigsSchema,
|
||||
ProjectType,
|
||||
SecretFoldersSchema,
|
||||
UserEncryptionKeysSchema,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
@ -307,7 +309,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
.max(256, { message: "Description must be 256 or fewer characters" })
|
||||
.optional()
|
||||
.describe(PROJECTS.UPDATE.projectDescription),
|
||||
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization)
|
||||
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/,
|
||||
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
||||
)
|
||||
.max(64, { message: "Slug must be 64 characters or fewer" })
|
||||
.optional()
|
||||
.describe(PROJECTS.UPDATE.slug)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -325,7 +337,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
update: {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
autoCapitalization: req.body.autoCapitalization
|
||||
autoCapitalization: req.body.autoCapitalization,
|
||||
slug: req.body.slug
|
||||
},
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
@ -664,4 +677,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
return slackConfig;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:workspaceId/environment-folder-tree",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
workspaceId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.record(
|
||||
ProjectEnvironmentsSchema.extend({ folders: SecretFoldersSchema.extend({ path: z.string() }).array() })
|
||||
)
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const environmentsFolders = await server.services.folder.getProjectEnvironmentsFolders(
|
||||
req.params.workspaceId,
|
||||
req.permission
|
||||
);
|
||||
|
||||
return environmentsFolders;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns";
|
||||
import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type";
|
||||
import { AuthMode } from "@app/services/auth/auth-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
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
@ -94,7 +94,7 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) =>
|
||||
iv: z.string().trim(),
|
||||
tag: z.string().trim(),
|
||||
expiresIn: z.number().nullable(),
|
||||
permissions: z.enum(["read", "write", "readValue"]).array()
|
||||
permissions: z.enum(["read", "write"]).array()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -354,6 +354,41 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/raw/id/:secretId",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
secretId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secret: secretRawSchema.extend({
|
||||
secretPath: z.string(),
|
||||
tags: SanitizedTagSchema.array().optional(),
|
||||
secretMetadata: ResourceMetadataSchema.optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { secretId } = req.params;
|
||||
const secret = await server.services.secret.getSecretByIdRaw({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
secretId
|
||||
});
|
||||
|
||||
return { secret };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/raw/:secretName",
|
||||
|
@ -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,7 +4,10 @@ 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 { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
@ -12,10 +15,13 @@ 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";
|
||||
@ -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;
|
||||
|
@ -772,6 +772,10 @@ export const importDataIntoInfisicalFn = async ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
});
|
||||
}
|
||||
|
@ -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";
|
||||
@ -78,14 +78,22 @@ export const identityJwtAuthServiceFactory = ({
|
||||
let tokenData: Record<string, string | boolean | number> = {};
|
||||
|
||||
if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) {
|
||||
const decryptedJwksCaCert = orgDataKeyDecryptor({
|
||||
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
|
||||
}).toString();
|
||||
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
|
||||
const client = new JwksClient({
|
||||
jwksUri: identityJwtAuth.jwksUrl,
|
||||
requestAgent
|
||||
});
|
||||
let client: JwksClient;
|
||||
if (identityJwtAuth.jwksUrl.includes("https:")) {
|
||||
const decryptedJwksCaCert = orgDataKeyDecryptor({
|
||||
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
|
||||
}).toString();
|
||||
|
||||
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
|
||||
client = new JwksClient({
|
||||
jwksUri: identityJwtAuth.jwksUrl,
|
||||
requestAgent
|
||||
});
|
||||
} else {
|
||||
client = new JwksClient({
|
||||
jwksUri: identityJwtAuth.jwksUrl
|
||||
});
|
||||
}
|
||||
|
||||
const { kid } = decodedToken.header;
|
||||
const jwtSigningKey = await client.getSigningKey(kid);
|
||||
@ -508,11 +516,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, {
|
||||
|
@ -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);
|
||||
|
||||
|
@ -114,20 +114,27 @@ export const integrationAuthServiceFactory = ({
|
||||
const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => {
|
||||
const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string);
|
||||
|
||||
return Promise.all(
|
||||
authorizations.filter(async (auth) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: auth.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
const filteredAuthorizations = await Promise.all(
|
||||
authorizations.map(async (auth) => {
|
||||
try {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: auth.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
|
||||
return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations) ? auth : null;
|
||||
} catch (error) {
|
||||
// user does not belong to the project that the integration auth belongs to
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return filteredAuthorizations.filter((auth): auth is NonNullable<typeof auth> => auth !== null);
|
||||
};
|
||||
|
||||
const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
@ -95,13 +96,10 @@ export const integrationServiceFactory = ({
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath);
|
||||
if (!folder) {
|
||||
@ -178,13 +176,10 @@ export const integrationServiceFactory = ({
|
||||
const newSecretPath = secretPath || integration.secretPath;
|
||||
|
||||
if (environment || secretPath) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: newEnvironment,
|
||||
secretPath: newSecretPath
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: newEnvironment,
|
||||
secretPath: newSecretPath
|
||||
});
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(integration.projectId, newEnvironment, newSecretPath);
|
||||
|
@ -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
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
} from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
@ -567,11 +568,24 @@ export const projectServiceFactory = ({
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
|
||||
|
||||
if (update.slug) {
|
||||
const existingProject = await projectDAL.findOne({
|
||||
slug: update.slug,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
if (existingProject && existingProject.id !== project.id) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProject = await projectDAL.updateById(project.id, {
|
||||
name: update.name,
|
||||
description: update.description,
|
||||
autoCapitalization: update.autoCapitalization,
|
||||
enforceCapitalization: update.autoCapitalization
|
||||
enforceCapitalization: update.autoCapitalization,
|
||||
slug: update.slug
|
||||
});
|
||||
|
||||
return updatedProject;
|
||||
@ -751,10 +765,7 @@ export const projectServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.Any
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
|
||||
|
||||
const project = await projectDAL.findProjectById(projectId);
|
||||
|
||||
|
@ -82,6 +82,7 @@ export type TUpdateProjectDTO = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
autoCapitalization?: boolean;
|
||||
slug?: string;
|
||||
};
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
|
17
backend/src/services/secret-folder/secret-folder-fns.ts
Normal file
17
backend/src/services/secret-folder/secret-folder-fns.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { TSecretFolders } from "@app/db/schemas";
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
|
||||
export const buildFolderPath = (
|
||||
folder: TSecretFolders,
|
||||
foldersMap: Record<string, TSecretFolders>,
|
||||
depth: number = 0
|
||||
): string => {
|
||||
if (depth > 20) {
|
||||
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
|
||||
}
|
||||
if (!folder.parentId) {
|
||||
return depth === 0 ? "/" : "";
|
||||
}
|
||||
|
||||
return `${buildFolderPath(foldersMap[folder.parentId], foldersMap, depth + 1)}/${folder.name}`;
|
||||
};
|
@ -8,6 +8,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
|
||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
||||
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
||||
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
@ -27,7 +28,7 @@ type TSecretFolderServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
|
||||
folderVersionDAL: TSecretFolderVersionDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
};
|
||||
@ -580,6 +581,44 @@ export const secretFolderServiceFactory = ({
|
||||
return folders;
|
||||
};
|
||||
|
||||
const getProjectEnvironmentsFolders = async (projectId: string, actor: OrgServiceActor) => {
|
||||
// folder list is allowed to be read by anyone
|
||||
// permission is to check if user has access
|
||||
await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
projectId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
const environments = await projectEnvDAL.find({ projectId });
|
||||
|
||||
const folders = await folderDAL.find({
|
||||
$in: {
|
||||
envId: environments.map((env) => env.id)
|
||||
},
|
||||
isReserved: false
|
||||
});
|
||||
|
||||
const environmentFolders = Object.fromEntries(
|
||||
environments.map((env) => {
|
||||
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
||||
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
||||
|
||||
const foldersWithPath = relevantFolders.map((folder) => ({
|
||||
...folder,
|
||||
path: buildFolderPath(folder, foldersMap)
|
||||
}));
|
||||
|
||||
return [env.slug, { ...env, folders: foldersWithPath }];
|
||||
})
|
||||
);
|
||||
|
||||
return environmentFolders;
|
||||
};
|
||||
|
||||
return {
|
||||
createFolder,
|
||||
updateFolder,
|
||||
@ -589,6 +628,7 @@ export const secretFolderServiceFactory = ({
|
||||
getFolderById,
|
||||
getProjectFolderCount,
|
||||
getFoldersMultiEnv,
|
||||
getFoldersDeepByEnvs
|
||||
getFoldersDeepByEnvs,
|
||||
getProjectEnvironmentsFolders
|
||||
};
|
||||
};
|
||||
|
@ -4,6 +4,10 @@ import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TableName } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import {
|
||||
hasSecretReadValueOrDescribePermission,
|
||||
throwIfMissingSecretReadValueOrDescribePermission
|
||||
} from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
@ -93,13 +97,11 @@ export const secretImportServiceFactory = ({
|
||||
);
|
||||
|
||||
// check if user has permission to import from target path
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: data.environment,
|
||||
secretPath: data.path
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: data.environment,
|
||||
secretPath: data.path
|
||||
});
|
||||
|
||||
if (isReplication) {
|
||||
const plan = await licenseService.getPlan(actorOrgId);
|
||||
if (!plan.secretApproval) {
|
||||
@ -405,13 +407,10 @@ export const secretImportServiceFactory = ({
|
||||
if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" });
|
||||
|
||||
// check if user has permission to import from target path
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: secretImportDoc.importEnv.slug,
|
||||
secretPath: secretImportDoc.importPath
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: secretImportDoc.importEnv.slug,
|
||||
secretPath: secretImportDoc.importPath
|
||||
});
|
||||
|
||||
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||
|
||||
@ -599,14 +598,12 @@ export const secretImportServiceFactory = ({
|
||||
// so anything based on this order will also be in right position
|
||||
const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false });
|
||||
const allowedImports = secretImports.filter((el) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: el.importEnv.slug,
|
||||
secretPath: el.importPath
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: el.importEnv.slug,
|
||||
secretPath: el.importPath
|
||||
})
|
||||
);
|
||||
|
||||
return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL });
|
||||
};
|
||||
|
||||
@ -651,16 +648,14 @@ export const secretImportServiceFactory = ({
|
||||
secretImportDAL,
|
||||
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""),
|
||||
hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
});
|
||||
|
||||
return importedSecrets;
|
||||
}
|
||||
|
||||
@ -671,13 +666,10 @@ export const secretImportServiceFactory = ({
|
||||
});
|
||||
|
||||
const allowedImports = secretImports.filter((el) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: el.importEnv.slug,
|
||||
secretPath: el.importPath
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: el.importEnv.slug,
|
||||
secretPath: el.importPath
|
||||
})
|
||||
);
|
||||
const importedSecrets = await fnSecretsFromImports({
|
||||
allowedImports,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
@ -178,13 +179,10 @@ export const secretSyncServiceFactory = ({
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
ForbiddenError.from(projectPermission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(projectPermission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
|
||||
@ -269,13 +267,10 @@ export const secretSyncServiceFactory = ({
|
||||
if (!updatedEnvironment || !updatedSecretPath)
|
||||
throw new BadRequestError({ message: "Must specify both source environment and secret path" });
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: updatedEnvironment,
|
||||
secretPath: updatedSecretPath
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: updatedEnvironment,
|
||||
secretPath: updatedSecretPath
|
||||
});
|
||||
|
||||
const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath);
|
||||
|
||||
|
@ -613,6 +613,9 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
|
||||
.leftJoin(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`)
|
||||
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
|
||||
.select(selectAllTableCols(TableName.SecretV2))
|
||||
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||
@ -622,12 +625,13 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
|
||||
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
|
||||
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
|
||||
);
|
||||
)
|
||||
.select(db.ref("projectId").withSchema(TableName.Environment).as("projectId"));
|
||||
|
||||
const docs = sqlNestRelationships({
|
||||
data: rawDocs,
|
||||
key: "id",
|
||||
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
|
||||
parentMapper: (el) => ({ _id: el.id, projectId: el.projectId, ...SecretsV2Schema.parse(el) }),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "tagId",
|
||||
|
@ -5,6 +5,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
|
||||
import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns";
|
||||
@ -63,6 +64,7 @@ export const fnSecretBulkInsert = async ({
|
||||
resourceMetadataDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor,
|
||||
tx
|
||||
}: TFnSecretBulkInsert) => {
|
||||
const sanitizedInputSecrets = inputSecrets.map(
|
||||
@ -91,6 +93,10 @@ export const fnSecretBulkInsert = async ({
|
||||
})
|
||||
);
|
||||
|
||||
const userActorId = actor && actor.type === ActorType.USER ? actor.actorId : undefined;
|
||||
const identityActorId = actor && actor.type !== ActorType.USER ? actor.actorId : undefined;
|
||||
const actorType = actor?.type || ActorType.PLATFORM;
|
||||
|
||||
const newSecrets = await secretDAL.insertMany(
|
||||
sanitizedInputSecrets.map((el) => ({ ...el, folderId })),
|
||||
tx
|
||||
@ -108,6 +114,9 @@ export const fnSecretBulkInsert = async ({
|
||||
sanitizedInputSecrets.map((el) => ({
|
||||
...el,
|
||||
folderId,
|
||||
userActorId,
|
||||
identityActorId,
|
||||
actorType,
|
||||
secretId: newSecretGroupedByKeyName[el.key][0].id
|
||||
})),
|
||||
tx
|
||||
@ -169,8 +178,13 @@ export const fnSecretBulkUpdate = async ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
resourceMetadataDAL
|
||||
resourceMetadataDAL,
|
||||
actor
|
||||
}: TFnSecretBulkUpdate) => {
|
||||
const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined;
|
||||
const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined;
|
||||
const actorType = actor?.type || ActorType.PLATFORM;
|
||||
|
||||
const sanitizedInputSecrets = inputSecrets.map(
|
||||
({
|
||||
filter,
|
||||
@ -228,7 +242,10 @@ export const fnSecretBulkUpdate = async ({
|
||||
encryptedValue,
|
||||
reminderRepeatDays,
|
||||
folderId,
|
||||
secretId
|
||||
secretId,
|
||||
userActorId,
|
||||
identityActorId,
|
||||
actorType
|
||||
})
|
||||
),
|
||||
tx
|
||||
@ -636,6 +653,12 @@ export const reshapeBridgeSecret = (
|
||||
secret: Omit<TSecretsV2, "encryptedValue" | "encryptedComment"> & {
|
||||
value: string;
|
||||
comment: string;
|
||||
userActorName?: string | null;
|
||||
identityActorName?: string | null;
|
||||
userActorId?: string | null;
|
||||
identityActorId?: string | null;
|
||||
membershipId?: string | null;
|
||||
actorType?: string | null;
|
||||
tags?: {
|
||||
id: string;
|
||||
slug: string;
|
||||
@ -656,6 +679,14 @@ export const reshapeBridgeSecret = (
|
||||
_id: secret.id,
|
||||
id: secret.id,
|
||||
user: secret.userId,
|
||||
actor: secret.actorType
|
||||
? {
|
||||
actorType: secret.actorType,
|
||||
actorId: secret.userActorId || secret.identityActorId,
|
||||
name: secret.identityActorName || secret.userActorName,
|
||||
membershipId: secret.membershipId
|
||||
}
|
||||
: undefined,
|
||||
tags: secret.tags,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
secretReminderRepeatDays: secret.reminderRepeatDays,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ForbiddenError, PureAbility, subject } from "@casl/ability";
|
||||
import { ForbiddenError, MongoAbility, subject } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -10,10 +10,15 @@ import {
|
||||
TableName,
|
||||
TSecretsV2
|
||||
} from "@app/db/schemas";
|
||||
import {
|
||||
hasSecretReadValueOrDescribePermission,
|
||||
throwIfMissingSecretReadValueOrDescribePermission
|
||||
} from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||
@ -32,6 +37,7 @@ import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
||||
import { TSecretQueueFactory } from "../secret/secret-queue";
|
||||
import { TGetASecretByIdDTO } from "../secret/secret-types";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
|
||||
@ -54,6 +60,7 @@ import {
|
||||
TCreateSecretDTO,
|
||||
TDeleteManySecretDTO,
|
||||
TDeleteSecretDTO,
|
||||
TGetAccessibleSecretsDTO,
|
||||
TGetASecretDTO,
|
||||
TGetSecretReferencesTreeDTO,
|
||||
TGetSecretsDTO,
|
||||
@ -121,7 +128,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
}: TSecretV2BridgeServiceFactoryDep) => {
|
||||
const $validateSecretReferences = async (
|
||||
projectId: string,
|
||||
permission: PureAbility,
|
||||
permission: MongoAbility<ProjectPermissionSet>,
|
||||
references: ReturnType<typeof getAllSecretReferences>["nestedReferences"],
|
||||
tx?: Knex
|
||||
) => {
|
||||
@ -129,6 +136,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const uniqueReferenceEnvironmentSlugs = Array.from(new Set(references.map((el) => el.environment)));
|
||||
const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs, tx);
|
||||
|
||||
if (referencesEnvironments.length !== uniqueReferenceEnvironmentSlugs.length)
|
||||
throw new BadRequestError({
|
||||
message: `Referenced environment not found. Missing ${diff(
|
||||
@ -145,6 +153,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`);
|
||||
const referredSecrets = await secretDAL.find(
|
||||
{
|
||||
@ -192,15 +201,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const referredSecretsGroupBySecretKey = groupBy(referredSecrets, (i) => i.key);
|
||||
references.forEach((el) => {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: el.environment,
|
||||
secretPath: el.secretPath,
|
||||
secretName: el.secretKey,
|
||||
tags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug)
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: el.environment,
|
||||
secretPath: el.secretPath,
|
||||
secretName: el.secretKey,
|
||||
secretTags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug)
|
||||
});
|
||||
});
|
||||
|
||||
return referredSecrets;
|
||||
@ -275,6 +281,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const allSecretReferences = nestedReferences.concat(
|
||||
localReferences.map((el) => ({ secretKey: el, secretPath, environment }))
|
||||
);
|
||||
|
||||
await $validateSecretReferences(projectId, permission, allSecretReferences);
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
@ -311,6 +318,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
});
|
||||
|
||||
@ -507,6 +518,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
})
|
||||
);
|
||||
@ -531,16 +546,17 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: inputSecret.secretName,
|
||||
...(tagsToCheck.length && {
|
||||
secretTags: tagsToCheck.map((el) => el.slug)
|
||||
})
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
@ -642,14 +658,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
projectId
|
||||
});
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretToDelete.key,
|
||||
secretTags: secretToDelete.tags?.map((el) => el.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
@ -693,11 +710,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
|
||||
}
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
@ -743,11 +756,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return 0;
|
||||
@ -783,26 +792,25 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const decryptedSecrets = secrets
|
||||
.filter((el) =>
|
||||
projectPermission.can(
|
||||
filterByAction,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: groupedFolderMappings[el.folderId][0].environment,
|
||||
secretPath: groupedFolderMappings[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(projectPermission, filterByAction, {
|
||||
environment: groupedFolderMappings[el.folderId][0].environment,
|
||||
secretPath: groupedFolderMappings[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
)
|
||||
|
||||
.map((secret) => {
|
||||
// Note(Daniel): This is only relevant if the filterAction isn't set to ReadValue. This is needed for the frontend.
|
||||
const secretValueHidden = !projectPermission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
projectPermission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: groupedFolderMappings[secret.folderId][0].environment,
|
||||
secretPath: groupedFolderMappings[secret.folderId][0].path,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
@ -849,10 +857,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
if (!isInternal) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
|
||||
}
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
@ -904,15 +909,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: path,
|
||||
secretTags: params.tagSlugs
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
|
||||
|
||||
let paths: { folderId: string; path: string }[] = [];
|
||||
|
||||
@ -951,14 +948,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const decryptedSecrets = secrets
|
||||
.filter((el) => {
|
||||
const canDescribeSecret = permission.can(
|
||||
const canDescribeSecret = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath: groupedPaths[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!canDescribeSecret) {
|
||||
@ -968,14 +966,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
if (viewSecretValue) {
|
||||
// Recursive secret, should be filtered out
|
||||
if (groupedPaths[el.folderId][0].path !== path) {
|
||||
const canReadRecursiveSecretValue = permission.can(
|
||||
const canReadRecursiveSecretValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath: groupedPaths[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!canReadRecursiveSecretValue) {
|
||||
@ -984,15 +983,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
}
|
||||
|
||||
if (throwOnMissingReadValuePermission) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: groupedPaths[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: groupedPaths[el.folderId][0].path,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
});
|
||||
}
|
||||
// Else, we do nothing. Because we don't want to filter out the secret, OR throw an error.
|
||||
// If the user doesn't have access to read the value, in the below map function, we mask the secret value and return the secret with a hidden value.
|
||||
@ -1005,15 +1001,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const secretValueHidden =
|
||||
!viewSecretValue ||
|
||||
!permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: groupedPaths[secret.folderId][0].path,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
})
|
||||
);
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: groupedPaths[secret.folderId][0].path,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
});
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
projectId,
|
||||
@ -1038,15 +1031,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretDAL,
|
||||
decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined),
|
||||
canExpandValue: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
});
|
||||
|
||||
if (shouldExpandSecretReferences) {
|
||||
@ -1086,24 +1076,26 @@ export const secretV2BridgeServiceFactory = ({
|
||||
expandSecretReferences,
|
||||
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""),
|
||||
hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => {
|
||||
const canDescribe = permission.can(
|
||||
const canDescribe = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const canReadValue = permission.can(
|
||||
const canReadValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return viewSecretValue ? canDescribe && canReadValue : canDescribe;
|
||||
@ -1116,6 +1108,76 @@ export const secretV2BridgeServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => {
|
||||
const secret = await secretDAL.findOneWithTags({
|
||||
[`${TableName.SecretV2}.id` as "id"]: secretId
|
||||
});
|
||||
|
||||
if (!secret) {
|
||||
throw new NotFoundError({
|
||||
message: `Secret with ID '${secretId}' not found`,
|
||||
name: "GetSecretById"
|
||||
});
|
||||
}
|
||||
|
||||
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secret.projectId, [secret.folderId]);
|
||||
|
||||
if (!folderWithPath) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with id '${secret.folderId}' not found`,
|
||||
name: "GetSecretById"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: secret.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: folderWithPath.environmentSlug,
|
||||
secretPath: folderWithPath.path,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
});
|
||||
|
||||
if (secret.type === SecretType.Personal && secret.userId !== actorId) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "You are not allowed to access this secret",
|
||||
name: "GetSecretById"
|
||||
});
|
||||
}
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: secret.projectId
|
||||
});
|
||||
|
||||
const secretValue = secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "";
|
||||
|
||||
const secretComment = secret.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
|
||||
: "";
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
secret.projectId,
|
||||
folderWithPath.environmentSlug,
|
||||
folderWithPath.path,
|
||||
{
|
||||
...secret,
|
||||
value: secretValue,
|
||||
comment: secretComment
|
||||
},
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
const getSecretByName = async ({
|
||||
actorId,
|
||||
actor,
|
||||
@ -1188,15 +1250,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
})
|
||||
));
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: path,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
})
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath: path,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
});
|
||||
|
||||
// this will throw if the user doesn't have read value permission no matter what
|
||||
// because if its an expansion, it will fully depend on the value.
|
||||
@ -1206,15 +1265,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretDAL,
|
||||
decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined),
|
||||
canExpandValue: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => {
|
||||
return permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
);
|
||||
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1232,17 +1288,14 @@ export const secretV2BridgeServiceFactory = ({
|
||||
folderDAL,
|
||||
secretImportDAL,
|
||||
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""),
|
||||
expandSecretReferences: shouldExpandSecretReferences ? expandSecretReferences : undefined,
|
||||
expandSecretReferences: shouldExpandSecretReferences && viewSecretValue ? expandSecretReferences : undefined,
|
||||
hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => {
|
||||
return permission.can(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
);
|
||||
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretKey,
|
||||
secretTags: expandSecretTags
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1254,15 +1307,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (viewSecretValue) {
|
||||
if (
|
||||
!permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: importedSecret.environment,
|
||||
secretPath: importedSecrets[i].secretPath,
|
||||
secretName: importedSecret.key,
|
||||
secretTags: (importedSecret.secretTags || []).map((el) => el.slug)
|
||||
})
|
||||
) &&
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: importedSecret.environment,
|
||||
secretPath: importedSecrets[i].secretPath,
|
||||
secretName: importedSecret.key,
|
||||
secretTags: (importedSecret.secretTags || []).map((el) => el.slug)
|
||||
}) &&
|
||||
secretType !== SecretType.Personal
|
||||
) {
|
||||
throw new ForbiddenRequestError({
|
||||
@ -1294,7 +1344,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
let secretValue = secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "";
|
||||
if (shouldExpandSecretReferences && secretValue) {
|
||||
if (shouldExpandSecretReferences && secretValue && viewSecretValue) {
|
||||
// eslint-disable-next-line
|
||||
const expandedSecretValue = await expandSecretReferences({
|
||||
environment,
|
||||
@ -1310,15 +1360,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (viewSecretValue) {
|
||||
if (
|
||||
!permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: path,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
})
|
||||
) &&
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: path,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
}) &&
|
||||
secretType !== SecretType.Personal
|
||||
) {
|
||||
throw new ForbiddenRequestError({
|
||||
@ -1467,6 +1514,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
})
|
||||
);
|
||||
@ -1482,14 +1533,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
|
||||
return newSecrets.map((el) => {
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags?.map((i) => i.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
@ -1753,6 +1805,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
resourceMetadataDAL
|
||||
});
|
||||
|
||||
@ -1786,6 +1842,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretVersionDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
});
|
||||
|
||||
@ -1811,14 +1871,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
);
|
||||
|
||||
return updatedSecrets.map((el) => {
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath: el.secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
@ -1944,15 +2005,12 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const secretValueHidden =
|
||||
!secretToDeleteMatch ||
|
||||
!permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
|
||||
})
|
||||
);
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
|
||||
});
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
projectId,
|
||||
@ -2003,24 +2061,25 @@ export const secretV2BridgeServiceFactory = ({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: folder.projectId
|
||||
});
|
||||
|
||||
const secretVersions = await secretVersionDAL.findBySecretId(secretId, {
|
||||
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors(secretId, folder.projectId, {
|
||||
offset,
|
||||
limit,
|
||||
sort: [["createdAt", "desc"]]
|
||||
});
|
||||
return secretVersions.map((el) => {
|
||||
const secretValueHidden = permission.cannot(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: folder.environment.envSlug,
|
||||
secretPath: folderWithPath.path,
|
||||
secretName: el.key,
|
||||
...(el.tags?.length && {
|
||||
secretTags: el.tags.map((tag) => tag.slug)
|
||||
})
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
folder.projectId,
|
||||
folder.environment.envSlug,
|
||||
@ -2137,15 +2196,27 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
sourceSecrets.forEach((secret) => {
|
||||
for (const sourceAction of sourceActions) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
sourceAction,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
if (
|
||||
sourceAction === ProjectPermissionSecretActions.DescribeSecret ||
|
||||
sourceAction === ProjectPermissionSecretActions.ReadValue
|
||||
) {
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, sourceAction, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath: sourceSecretPath,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((el) => el.slug)
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
sourceAction,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath: sourceSecretPath,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((el) => el.slug)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -2292,6 +2363,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
inputSecrets: locallyCreatedSecrets.map((doc) => {
|
||||
return {
|
||||
type: doc.type,
|
||||
@ -2318,6 +2393,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
tx,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
actor: {
|
||||
type: actor,
|
||||
actorId
|
||||
},
|
||||
inputSecrets: locallyUpdatedSecrets.map((doc) => {
|
||||
return {
|
||||
filter: {
|
||||
@ -2460,10 +2539,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder)
|
||||
@ -2484,17 +2563,16 @@ export const secretV2BridgeServiceFactory = ({
|
||||
type: SecretType.Shared
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
})
|
||||
);
|
||||
if (!secret) throw new NotFoundError({ message: `Secret with name '${secretName}' not found` });
|
||||
|
||||
const decryptedSecretValue = secret.encryptedValue
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
});
|
||||
|
||||
const decryptedSecretValue = secret?.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "";
|
||||
|
||||
@ -2504,27 +2582,21 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretDAL,
|
||||
decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined),
|
||||
canExpandValue: (expandEnvironment, expandSecretPath, expandSecretName, expandSecretTags) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretName,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: expandEnvironment,
|
||||
secretPath: expandSecretPath,
|
||||
secretName: expandSecretName,
|
||||
secretTags: expandSecretTags
|
||||
})
|
||||
});
|
||||
|
||||
if (
|
||||
!permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
})
|
||||
)
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: (secret?.tags || []).map((el) => el.slug)
|
||||
})
|
||||
) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `Unable to get secret reference tree for secret with key '${secretName}', because you don't have permission to view secret value.`
|
||||
@ -2540,6 +2612,95 @@ export const secretV2BridgeServiceFactory = ({
|
||||
return { tree: stackTrace, value: expandedValue };
|
||||
};
|
||||
|
||||
const getAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return { secrets: [] };
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds([folder.id]);
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedSecrets = secrets
|
||||
.filter((el) => {
|
||||
if (
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterByAction === ProjectPermissionSecretActions.ReadValue) {
|
||||
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((secret) => {
|
||||
const secretValueHidden =
|
||||
filterByAction === ProjectPermissionSecretActions.DescribeSecret &&
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
});
|
||||
|
||||
return reshapeBridgeSecret(
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
{
|
||||
...secret,
|
||||
value: secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "",
|
||||
comment: secret.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
|
||||
: ""
|
||||
},
|
||||
secretValueHidden
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
secrets: decryptedSecrets
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createSecret,
|
||||
deleteSecret,
|
||||
@ -2556,6 +2717,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
getSecretsCountMultiEnv,
|
||||
getSecretsMultiEnv,
|
||||
getSecretReferenceTree,
|
||||
getSecretsByFolderMappings
|
||||
getSecretsByFolderMappings,
|
||||
getSecretById,
|
||||
getAccessibleSecrets
|
||||
};
|
||||
};
|
||||
|
@ -177,6 +177,10 @@ export type TFnSecretBulkInsert = {
|
||||
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "find">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||
actor?: {
|
||||
type: string;
|
||||
actorId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type TRequireReferenceIfValue =
|
||||
@ -201,6 +205,10 @@ export type TFnSecretBulkUpdate = {
|
||||
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2" | "find">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||
actor?: {
|
||||
type: string;
|
||||
actorId: string;
|
||||
};
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
@ -341,5 +349,12 @@ export type TGetSecretsRawByFolderMappingsDTO = {
|
||||
folderMappings: { folderId: string; path: string; environment: string }[];
|
||||
userId: string;
|
||||
filters: TFindSecretsByFolderIdsFilter;
|
||||
filterByAction?: ProjectPermissionSecretActions;
|
||||
filterByAction?: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
};
|
||||
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
environment: string;
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
@ -171,12 +172,101 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`);
|
||||
};
|
||||
|
||||
const findVersionsBySecretIdWithActors = async (
|
||||
secretId: string,
|
||||
projectId: string,
|
||||
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TSecretVersionsV2> = {},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db)(TableName.SecretVersionV2)
|
||||
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.leftJoin(
|
||||
TableName.ProjectMembership,
|
||||
`${TableName.ProjectMembership}.userId`,
|
||||
`${TableName.SecretVersionV2}.userActorId`
|
||||
)
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||
.leftJoin(
|
||||
TableName.SecretV2JnTag,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretTag,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.where((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.where(`${TableName.ProjectMembership}.projectId`, projectId);
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.whereNull(`${TableName.ProjectMembership}.projectId`);
|
||||
})
|
||||
.select(
|
||||
selectAllTableCols(TableName.SecretVersionV2),
|
||||
db.ref("username").withSchema(TableName.Users).as("userActorName"),
|
||||
db.ref("name").withSchema(TableName.Identity).as("identityActorName"),
|
||||
db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"),
|
||||
db.ref("id").withSchema(TableName.SecretTag).as("tagId"),
|
||||
db.ref("color").withSchema(TableName.SecretTag).as("tagColor"),
|
||||
db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")
|
||||
);
|
||||
|
||||
if (limit) void query.limit(limit);
|
||||
if (offset) void query.offset(offset);
|
||||
if (sort) {
|
||||
void query.orderBy(
|
||||
sort.map(([column, order, nulls]) => ({
|
||||
column: `${TableName.SecretVersionV2}.${column as string}`,
|
||||
order,
|
||||
nulls
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const docs = await query;
|
||||
|
||||
const data = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
parentMapper: (el) => ({
|
||||
_id: el.id,
|
||||
...SecretVersionsV2Schema.parse(el),
|
||||
userActorName: el.userActorName,
|
||||
identityActorName: el.identityActorName,
|
||||
membershipId: el.membershipId
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "tagId",
|
||||
label: "tags" as const,
|
||||
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
|
||||
id,
|
||||
color,
|
||||
slug,
|
||||
name: slug
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindVersionsBySecretIdWithActors" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...secretVersionV2Orm,
|
||||
pruneExcessVersions,
|
||||
findLatestVersionMany,
|
||||
bulkUpdate,
|
||||
findLatestVersionByFolderId,
|
||||
findVersionsBySecretIdWithActors,
|
||||
findBySecretId
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { subject } from "@casl/ability";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
@ -12,8 +11,9 @@ import {
|
||||
TSecretFolders,
|
||||
TSecrets
|
||||
} from "@app/db/schemas";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import {
|
||||
buildSecretBlindIndexFromName,
|
||||
@ -191,13 +191,10 @@ export const recursivelyGetSecretPaths = ({
|
||||
// Filter out paths that the user does not have permission to access, and paths that are not in the current path
|
||||
const allowedPaths = paths.filter(
|
||||
(folder) =>
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: folder.path
|
||||
})
|
||||
) && folder.path.startsWith(currentPath === "/" ? "" : currentPath)
|
||||
hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: folder.path
|
||||
}) && folder.path.startsWith(currentPath === "/" ? "" : currentPath)
|
||||
);
|
||||
|
||||
return allowedPaths;
|
||||
@ -585,6 +582,7 @@ export const fnSecretBulkInsert = async ({
|
||||
[`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id
|
||||
}))
|
||||
);
|
||||
|
||||
const secretVersions = await secretVersionDAL.insertMany(
|
||||
sanitizedInputSecrets.map((el) => ({
|
||||
...el,
|
||||
|
@ -61,6 +61,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TWebhookDALFactory } from "../webhook/webhook-dal";
|
||||
import { fnTriggerWebhook } from "../webhook/webhook-fns";
|
||||
import { WebhookEvents } from "../webhook/webhook-types";
|
||||
import { TSecretDALFactory } from "./secret-dal";
|
||||
import { interpolateSecrets } from "./secret-fns";
|
||||
import {
|
||||
@ -624,7 +625,14 @@ export const secretQueueFactory = ({
|
||||
await queueService.queue(
|
||||
QueueName.SecretWebhook,
|
||||
QueueJobs.SecWebhook,
|
||||
{ environment, projectId, secretPath },
|
||||
{
|
||||
type: WebhookEvents.SecretModified,
|
||||
payload: {
|
||||
environment,
|
||||
projectId,
|
||||
secretPath
|
||||
}
|
||||
},
|
||||
{
|
||||
jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`,
|
||||
removeOnFail: { count: 5 },
|
||||
@ -1056,6 +1064,8 @@ export const secretQueueFactory = ({
|
||||
|
||||
const organization = await orgDAL.findOrgByProjectId(projectId);
|
||||
const project = await projectDAL.findById(projectId);
|
||||
const secret = await secretV2BridgeDAL.findById(data.secretId);
|
||||
const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]);
|
||||
|
||||
if (!organization) {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`);
|
||||
@ -1084,6 +1094,19 @@ export const secretQueueFactory = ({
|
||||
organizationName: organization.name
|
||||
}
|
||||
});
|
||||
|
||||
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, {
|
||||
type: WebhookEvents.SecretReminderExpired,
|
||||
payload: {
|
||||
projectName: project.name,
|
||||
projectId: project.id,
|
||||
secretPath: folder?.path,
|
||||
environment: folder?.environmentSlug || "",
|
||||
reminderNote: data.note,
|
||||
secretName: secret?.key,
|
||||
secretId: data.secretId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const startSecretV2Migration = async (projectId: string) => {
|
||||
@ -1491,14 +1514,17 @@ export const secretQueueFactory = ({
|
||||
queueService.start(QueueName.SecretWebhook, async (job) => {
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: job.data.projectId
|
||||
projectId: job.data.payload.projectId
|
||||
});
|
||||
|
||||
await fnTriggerWebhook({
|
||||
...job.data,
|
||||
projectId: job.data.payload.projectId,
|
||||
environment: job.data.payload.environment,
|
||||
secretPath: job.data.payload.secretPath || "/",
|
||||
projectEnvDAL,
|
||||
webhookDAL,
|
||||
projectDAL,
|
||||
webhookDAL,
|
||||
event: job.data,
|
||||
secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString()
|
||||
});
|
||||
});
|
||||
|
@ -13,6 +13,10 @@ import {
|
||||
SecretType
|
||||
} from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import {
|
||||
hasSecretReadValueOrDescribePermission,
|
||||
throwIfMissingSecretReadValueOrDescribePermission
|
||||
} from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
@ -77,6 +81,8 @@ import {
|
||||
TDeleteManySecretRawDTO,
|
||||
TDeleteSecretDTO,
|
||||
TDeleteSecretRawDTO,
|
||||
TGetAccessibleSecretsDTO,
|
||||
TGetASecretByIdRawDTO,
|
||||
TGetASecretDTO,
|
||||
TGetASecretRawDTO,
|
||||
TGetSecretAccessListDTO,
|
||||
@ -451,12 +457,13 @@ export const secretServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath: path
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
@ -561,9 +568,13 @@ export const secretServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
{
|
||||
environment,
|
||||
secretPath: path
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
@ -621,10 +632,10 @@ export const secretServiceFactory = ({
|
||||
|
||||
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
|
||||
} else {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: path
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return { secrets: [], imports: [] };
|
||||
@ -646,13 +657,10 @@ export const secretServiceFactory = ({
|
||||
// if its service token allow full access over imported one
|
||||
actor === ActorType.SERVICE
|
||||
? true
|
||||
: permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: importEnv.slug,
|
||||
secretPath: importPath
|
||||
})
|
||||
)
|
||||
: hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: importEnv.slug,
|
||||
secretPath: importPath
|
||||
})
|
||||
);
|
||||
const importedSecrets = await fnSecretsFromImports({
|
||||
allowedImports,
|
||||
@ -703,10 +711,11 @@ export const secretServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: path
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder)
|
||||
throw new NotFoundError({
|
||||
@ -753,14 +762,12 @@ export const secretServiceFactory = ({
|
||||
// if its service token allow full access over imported one
|
||||
actor === ActorType.SERVICE
|
||||
? true
|
||||
: permission.can(
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: importEnv.slug,
|
||||
secretPath: importPath
|
||||
})
|
||||
)
|
||||
: hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment: importEnv.slug,
|
||||
secretPath: importPath
|
||||
})
|
||||
);
|
||||
|
||||
const importedSecrets = await fnSecretsFromImports({
|
||||
allowedImports,
|
||||
secretDAL,
|
||||
@ -974,9 +981,13 @@ export const secretServiceFactory = ({
|
||||
secretVersionTagDAL
|
||||
});
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
{
|
||||
environment,
|
||||
secretPath: path
|
||||
}
|
||||
);
|
||||
|
||||
return updatedSecrets.map((secret) => ({
|
||||
@ -1068,10 +1079,13 @@ export const secretServiceFactory = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const secretValueHidden = !permission.can(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
{
|
||||
environment,
|
||||
secretPath: path
|
||||
}
|
||||
);
|
||||
|
||||
return secrets.map((secret) => ({
|
||||
@ -1258,8 +1272,20 @@ export const secretServiceFactory = ({
|
||||
ProjectPermissionSecretActions.Delete,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
ProjectPermissionSecretActions.Edit
|
||||
].filter((action) =>
|
||||
entityPermission.permission.can(
|
||||
].filter((action) => {
|
||||
if (
|
||||
action === ProjectPermissionSecretActions.DescribeSecret ||
|
||||
action === ProjectPermissionSecretActions.ReadValue
|
||||
) {
|
||||
return hasSecretReadValueOrDescribePermission(entityPermission.permission, action, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: secret?.tags?.map((el) => el.slug)
|
||||
});
|
||||
}
|
||||
|
||||
return entityPermission.permission.can(
|
||||
action,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
@ -1267,8 +1293,8 @@ export const secretServiceFactory = ({
|
||||
secretName,
|
||||
secretTags: secret?.tags?.map((el) => el.slug)
|
||||
})
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...entityPermission,
|
||||
@ -1287,6 +1313,39 @@ export const secretServiceFactory = ({
|
||||
return { users: usersWithAccess, identities: identitiesWithAccess, groups: groupsWithAccess };
|
||||
};
|
||||
|
||||
const getAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environment,
|
||||
filterByAction
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
if (!shouldUseSecretV2Bridge) {
|
||||
throw new BadRequestError({
|
||||
message: "Project version does not support this endpoint.",
|
||||
name: "ProjectVersionNotSupported"
|
||||
});
|
||||
}
|
||||
|
||||
const secrets = await secretV2BridgeService.getAccessibleSecrets({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
});
|
||||
|
||||
return secrets;
|
||||
};
|
||||
|
||||
const getSecretsRaw = async ({
|
||||
projectId,
|
||||
path,
|
||||
@ -1456,6 +1515,18 @@ export const secretServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => {
|
||||
const secret = await secretV2BridgeService.getSecretById({
|
||||
secretId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
});
|
||||
|
||||
return secret;
|
||||
};
|
||||
|
||||
const getSecretByNameRaw = async ({
|
||||
type,
|
||||
path,
|
||||
@ -2411,16 +2482,17 @@ export const secretServiceFactory = ({
|
||||
key: botKey
|
||||
});
|
||||
|
||||
const secretValueHidden = permission.cannot(
|
||||
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment: folder.environment.envSlug,
|
||||
secretPath: folderWithPath.path,
|
||||
secretName: secretKey,
|
||||
...(el.tags?.length && {
|
||||
secretTags: el.tags.map((tag) => tag.slug)
|
||||
})
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return decryptSecretRaw(
|
||||
@ -2820,13 +2892,23 @@ export const secretServiceFactory = ({
|
||||
}
|
||||
|
||||
for (const sourceAction of sourceActions) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
sourceAction,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
if (
|
||||
sourceAction === ProjectPermissionSecretActions.ReadValue ||
|
||||
sourceAction === ProjectPermissionSecretActions.DescribeSecret
|
||||
) {
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, sourceAction, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath: sourceSecretPath
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
sourceAction,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: sourceEnvironment,
|
||||
secretPath: sourceSecretPath
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@ -3212,6 +3294,8 @@ export const secretServiceFactory = ({
|
||||
getSecretsRawMultiEnv,
|
||||
getSecretReferenceTree,
|
||||
getSecretsRawByFolderMappings,
|
||||
getSecretAccessList
|
||||
getSecretAccessList,
|
||||
getSecretByIdRaw,
|
||||
getAccessibleSecrets
|
||||
};
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
|
||||
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||
@ -121,6 +122,10 @@ export type TGetASecretDTO = {
|
||||
version?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretByIdDTO = {
|
||||
secretId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateBulkSecretDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
@ -176,6 +181,12 @@ export enum SecretsOrderBy {
|
||||
Name = "name" // "key" for secrets but using name for use across resources
|
||||
}
|
||||
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetSecretsRawDTO = {
|
||||
expandSecretReferences?: boolean;
|
||||
path: string;
|
||||
@ -216,6 +227,10 @@ export type TGetASecretRawDTO = {
|
||||
projectId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetASecretByIdRawDTO = {
|
||||
secretId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateSecretRawDTO = TProjectPermission & {
|
||||
secretName: string;
|
||||
secretPath: string;
|
||||
|
@ -7,7 +7,7 @@ export type TCreateServiceTokenDTO = {
|
||||
iv: string;
|
||||
tag: string;
|
||||
expiresIn?: number | null;
|
||||
permissions: ("read" | "write" | "readValue")[];
|
||||
permissions: ("read" | "write")[];
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetServiceTokenInfoDTO = Omit<TProjectPermission, "projectId">;
|
||||
|
@ -50,6 +50,7 @@ const buildSlackPayload = (notification: TSlackNotification) => {
|
||||
const messageBody = `A secret approval request has been opened by ${payload.userEmail}.
|
||||
*Environment*: ${payload.environment}
|
||||
*Secret path*: ${payload.secretPath || "/"}
|
||||
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}
|
||||
|
||||
View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${
|
||||
payload.requestId
|
||||
|
@ -62,6 +62,7 @@ export type TSlackNotification =
|
||||
secretPath: string;
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
secretKeys: string[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
@ -271,22 +271,17 @@ 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
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
if (!licenseService.onPremFeatures?.instanceUserManagement) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to delete user due to plan restriction. Upgrade to Infisical's Pro plan."
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userDAL.deleteById(userId);
|
||||
return user;
|
||||
};
|
||||
|
@ -20,6 +20,7 @@ export type TAdminGetUsersDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TWebhookDALFactory } from "./webhook-dal";
|
||||
import { WebhookType } from "./webhook-types";
|
||||
import { TWebhookPayloads, WebhookEvents, WebhookType } from "./webhook-types";
|
||||
|
||||
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
|
||||
|
||||
@ -54,29 +54,64 @@ export const triggerWebhookRequest = async (
|
||||
return req;
|
||||
};
|
||||
|
||||
export const getWebhookPayload = (
|
||||
eventName: string,
|
||||
details: {
|
||||
workspaceName: string;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
type?: string | null;
|
||||
export const getWebhookPayload = (event: TWebhookPayloads) => {
|
||||
if (event.type === WebhookEvents.SecretModified) {
|
||||
const { projectName, projectId, environment, secretPath, type } = event.payload;
|
||||
|
||||
switch (type) {
|
||||
case WebhookType.SLACK:
|
||||
return {
|
||||
text: "A secret value has been added or modified.",
|
||||
attachments: [
|
||||
{
|
||||
color: "#E7F256",
|
||||
fields: [
|
||||
{
|
||||
title: "Project",
|
||||
value: projectName,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Environment",
|
||||
value: environment,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Secret Path",
|
||||
value: secretPath,
|
||||
short: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
case WebhookType.GENERAL:
|
||||
default:
|
||||
return {
|
||||
event: event.type,
|
||||
project: {
|
||||
workspaceId: projectId,
|
||||
projectName,
|
||||
environment,
|
||||
secretPath
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
) => {
|
||||
const { workspaceName, workspaceId, environment, secretPath, type } = details;
|
||||
|
||||
const { projectName, projectId, environment, secretPath, type, reminderNote, secretName } = event.payload;
|
||||
|
||||
switch (type) {
|
||||
case WebhookType.SLACK:
|
||||
return {
|
||||
text: "A secret value has been added or modified.",
|
||||
text: "You have a secret reminder",
|
||||
attachments: [
|
||||
{
|
||||
color: "#E7F256",
|
||||
fields: [
|
||||
{
|
||||
title: "Project",
|
||||
value: workspaceName,
|
||||
value: projectName,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
@ -88,6 +123,16 @@ export const getWebhookPayload = (
|
||||
title: "Secret Path",
|
||||
value: secretPath,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Secret Name",
|
||||
value: secretName,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Reminder Note",
|
||||
value: reminderNote,
|
||||
short: false
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -96,11 +141,14 @@ export const getWebhookPayload = (
|
||||
case WebhookType.GENERAL:
|
||||
default:
|
||||
return {
|
||||
event: eventName,
|
||||
event: event.type,
|
||||
project: {
|
||||
workspaceId,
|
||||
workspaceId: projectId,
|
||||
projectName,
|
||||
environment,
|
||||
secretPath
|
||||
secretPath,
|
||||
secretName,
|
||||
reminderNote
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -110,6 +158,7 @@ export type TFnTriggerWebhookDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
event: TWebhookPayloads;
|
||||
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
@ -124,8 +173,9 @@ export const fnTriggerWebhook = async ({
|
||||
projectId,
|
||||
webhookDAL,
|
||||
projectEnvDAL,
|
||||
projectDAL,
|
||||
secretManagerDecryptor
|
||||
event,
|
||||
secretManagerDecryptor,
|
||||
projectDAL
|
||||
}: TFnTriggerWebhookDTO) => {
|
||||
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment);
|
||||
const toBeTriggeredHooks = webhooks.filter(
|
||||
@ -134,21 +184,20 @@ export const fnTriggerWebhook = async ({
|
||||
);
|
||||
if (!toBeTriggeredHooks.length) return;
|
||||
logger.info({ environment, secretPath, projectId }, "Secret webhook job started");
|
||||
const project = await projectDAL.findById(projectId);
|
||||
let { projectName } = event.payload;
|
||||
if (!projectName) {
|
||||
const project = await projectDAL.findById(event.payload.projectId);
|
||||
projectName = project.name;
|
||||
}
|
||||
|
||||
const webhooksTriggered = await Promise.allSettled(
|
||||
toBeTriggeredHooks.map((hook) =>
|
||||
triggerWebhookRequest(
|
||||
hook,
|
||||
secretManagerDecryptor,
|
||||
getWebhookPayload("secrets.modified", {
|
||||
workspaceName: project.name,
|
||||
workspaceId: projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
type: hook.type
|
||||
})
|
||||
)
|
||||
)
|
||||
toBeTriggeredHooks.map((hook) => {
|
||||
const formattedEvent = {
|
||||
type: event.type,
|
||||
payload: { ...event.payload, type: hook.type, projectName }
|
||||
} as TWebhookPayloads;
|
||||
return triggerWebhookRequest(hook, secretManagerDecryptor, getWebhookPayload(formattedEvent));
|
||||
})
|
||||
);
|
||||
|
||||
// filter hooks by status
|
||||
|
@ -16,7 +16,8 @@ import {
|
||||
TDeleteWebhookDTO,
|
||||
TListWebhookDTO,
|
||||
TTestWebhookDTO,
|
||||
TUpdateWebhookDTO
|
||||
TUpdateWebhookDTO,
|
||||
WebhookEvents
|
||||
} from "./webhook-types";
|
||||
|
||||
type TWebhookServiceFactoryDep = {
|
||||
@ -144,12 +145,15 @@ export const webhookServiceFactory = ({
|
||||
await triggerWebhookRequest(
|
||||
webhook,
|
||||
(value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(),
|
||||
getWebhookPayload("test", {
|
||||
workspaceName: project.name,
|
||||
workspaceId: webhook.projectId,
|
||||
environment: webhook.environment.slug,
|
||||
secretPath: webhook.secretPath,
|
||||
type: webhook.type
|
||||
getWebhookPayload({
|
||||
type: "test" as WebhookEvents.SecretModified,
|
||||
payload: {
|
||||
projectName: project.name,
|
||||
projectId: webhook.projectId,
|
||||
environment: webhook.environment.slug,
|
||||
secretPath: webhook.secretPath,
|
||||
type: webhook.type
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
|
@ -30,3 +30,36 @@ export enum WebhookType {
|
||||
GENERAL = "general",
|
||||
SLACK = "slack"
|
||||
}
|
||||
|
||||
export enum WebhookEvents {
|
||||
SecretModified = "secrets.modified",
|
||||
SecretReminderExpired = "secrets.reminder-expired",
|
||||
TestEvent = "test"
|
||||
}
|
||||
|
||||
type TWebhookSecretModifiedEventPayload = {
|
||||
type: WebhookEvents.SecretModified;
|
||||
payload: {
|
||||
projectName?: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
type?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type TWebhookSecretReminderEventPayload = {
|
||||
type: WebhookEvents.SecretReminderExpired;
|
||||
payload: {
|
||||
projectName?: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
type?: string | null;
|
||||
secretName: string;
|
||||
secretId: string;
|
||||
reminderNote?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TWebhookPayloads = TWebhookSecretModifiedEventPayload | TWebhookSecretReminderEventPayload;
|
||||
|
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")
|
||||
}
|
||||
}
|
||||
});
|
8
cli/config/example-infisical-relay.yaml
Normal file
8
cli/config/example-infisical-relay.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
public_ip: 127.0.0.1
|
||||
auth_secret: example-auth-secret
|
||||
realm: infisical.org
|
||||
# set port 5349 for tls
|
||||
# port: 5349
|
||||
# tls_private_key_path: /full-path
|
||||
# tls_ca_path: /full-path
|
||||
# tls_cert_path: /full-path
|
16
cli/go.mod
16
cli/go.mod
@ -20,6 +20,7 @@ require (
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/roff v0.1.0
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9
|
||||
github.com/pion/dtls/v3 v3.0.4
|
||||
github.com/pion/logging v0.2.3
|
||||
github.com/pion/turn/v4 v4.0.0
|
||||
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a
|
||||
@ -28,9 +29,10 @@ require (
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/term v0.29.0
|
||||
github.com/stretchr/testify v1.10.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
|
||||
)
|
||||
|
||||
@ -89,7 +91,6 @@ require (
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.2 // indirect
|
||||
github.com/pelletier/go-toml v1.9.3 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
@ -114,9 +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/sys v0.30.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
|
||||
@ -139,3 +139,5 @@ require (
|
||||
)
|
||||
|
||||
replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.2
|
||||
|
||||
replace github.com/pion/turn/v4 => github.com/Infisical/turn/v4 v4.0.1
|
||||
|
30
cli/go.sum
30
cli/go.sum
@ -49,6 +49,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8=
|
||||
github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto=
|
||||
github.com/Infisical/turn/v4 v4.0.1 h1:omdelNsnFfzS5cu86W5OBR68by68a8sva4ogR0lQQnw=
|
||||
github.com/Infisical/turn/v4 v4.0.1/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
@ -365,8 +367,6 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@ -425,8 +425,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||
@ -484,8 +484,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
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=
|
||||
@ -590,8 +590,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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=
|
||||
@ -640,11 +640,11 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
@ -654,8 +654,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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=
|
||||
@ -858,4 +858,4 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
@ -1,39 +1,34 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
// "fmt"
|
||||
|
||||
// "github.com/Infisical/infisical-merge/packages/api"
|
||||
// "github.com/Infisical/infisical-merge/packages/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var gatewayCmd = &cobra.Command{
|
||||
Example: `infisical gateway`,
|
||||
Short: "Used to infisical gateway",
|
||||
Use: "gateway",
|
||||
Short: "Run the Infisical gateway or manage its systemd service",
|
||||
Long: "Run the Infisical gateway in the foreground or manage its systemd service installation. Use 'gateway install' to set up the systemd service.",
|
||||
Example: `infisical gateway --token=<token>
|
||||
sudo infisical gateway install --token=<token> --domain=<domain>`,
|
||||
DisableFlagsInUseLine: true,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
token, err := util.GetInfisicalToken(cmd)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
util.HandleError(err, "Unable to parse token flag")
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
@ -109,6 +104,50 @@ var gatewayCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var gatewayInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and enable systemd service for the gateway (requires sudo)",
|
||||
Long: "Install and enable systemd service for the gateway. Must be run with sudo on Linux.",
|
||||
Example: "sudo infisical gateway install --token=<token> --domain=<domain>",
|
||||
DisableFlagsInUseLine: true,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if runtime.GOOS != "linux" {
|
||||
util.HandleError(fmt.Errorf("systemd service installation is only supported on Linux"))
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
util.HandleError(fmt.Errorf("systemd service installation requires root/sudo privileges"))
|
||||
}
|
||||
|
||||
token, err := util.GetInfisicalToken(cmd)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
util.HandleError(fmt.Errorf("Token not found"))
|
||||
}
|
||||
|
||||
domain, err := cmd.Flags().GetString("domain")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse domain flag")
|
||||
}
|
||||
|
||||
if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil {
|
||||
util.HandleError(err, "Failed to install systemd service")
|
||||
}
|
||||
|
||||
enableCmd := exec.Command("systemctl", "enable", "infisical-gateway")
|
||||
if err := enableCmd.Run(); err != nil {
|
||||
util.HandleError(err, "Failed to enable systemd service")
|
||||
}
|
||||
|
||||
log.Info().Msg("Successfully installed and enabled infisical-gateway service")
|
||||
log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway")
|
||||
},
|
||||
}
|
||||
|
||||
var gatewayRelayCmd = &cobra.Command{
|
||||
Example: `infisical gateway relay`,
|
||||
Short: "Used to run infisical gateway relay",
|
||||
@ -137,15 +176,13 @@ var gatewayRelayCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
gatewayCmd.SetHelpFunc(func(command *cobra.Command, strings []string) {
|
||||
command.Flags().MarkHidden("domain")
|
||||
command.Parent().HelpFunc()(command, strings)
|
||||
})
|
||||
gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
|
||||
gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
|
||||
gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance")
|
||||
|
||||
gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path")
|
||||
|
||||
gatewayCmd.AddCommand(gatewayInstallCmd)
|
||||
gatewayCmd.AddCommand(gatewayRelayCmd)
|
||||
|
||||
rootCmd.AddCommand(gatewayCmd)
|
||||
}
|
||||
|
@ -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, relayUdpConnection.LocalAddr().String(), 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
|
||||
@ -320,41 +326,31 @@ func (g *Gateway) createPermissionForStaticIps(staticIps string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) registerRelayIsActive(ctx context.Context, relayAddress string, errCh chan error) error {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) error {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
maxFailures := 3
|
||||
failures := 0
|
||||
|
||||
log.Info().Msg("Starting relay connection health check")
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("Stopping relay connection health check")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Configure TLS to skip verification
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"infisical-gateway"},
|
||||
}
|
||||
quicConfig := &quic.Config{
|
||||
EnableDatagrams: true,
|
||||
}
|
||||
func() {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
conn, err := quic.DialAddr(checkCtx, relayAddress, tlsConfig, quicConfig)
|
||||
if err != nil {
|
||||
failures++
|
||||
log.Warn().Err(err).Int("failures", failures).Msg("Relay connection check failed")
|
||||
if failures >= maxFailures {
|
||||
errCh <- fmt.Errorf("relay connection check failed: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
if conn != nil {
|
||||
conn.CloseWithError(0, "closed")
|
||||
}
|
||||
}()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
@ -1,7 +1,9 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
@ -9,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"
|
||||
@ -105,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)
|
||||
}
|
||||
@ -114,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
|
||||
@ -129,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)
|
||||
|
37
cli/packages/gateway/relay_windows.go
Normal file
37
cli/packages/gateway/relay_windows.go
Normal file
@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingTlsCert = errors.New("Missing TLS files")
|
||||
errWindowsNotSupported = errors.New("Relay is not supported on Windows")
|
||||
)
|
||||
|
||||
type GatewayRelay struct {
|
||||
Config *GatewayRelayConfig
|
||||
}
|
||||
|
||||
type GatewayRelayConfig struct {
|
||||
PublicIP string
|
||||
Port int
|
||||
Realm string
|
||||
AuthSecret string
|
||||
RelayMinPort uint16
|
||||
RelayMaxPort uint16
|
||||
TlsCertPath string
|
||||
TlsPrivateKeyPath string
|
||||
TlsCaPath string
|
||||
}
|
||||
|
||||
func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) {
|
||||
return nil, errWindowsNotSupported
|
||||
}
|
||||
|
||||
func (g *GatewayRelay) Run() error {
|
||||
return errWindowsNotSupported
|
||||
}
|
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=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).
|
||||
|
107
docs/cli/commands/gateway.mdx
Normal file
107
docs/cli/commands/gateway.mdx
Normal file
@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "infisical gateway"
|
||||
description: "Run the Infisical gateway or manage its systemd service"
|
||||
---
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Run gateway">
|
||||
```bash
|
||||
infisical gateway --token=<token>
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Install service">
|
||||
```bash
|
||||
sudo infisical gateway install --token=<token> --domain=<domain>
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Description
|
||||
|
||||
Run the Infisical gateway in the foreground or manage its systemd service installation. The gateway allows secure communication between your self-hosted Infisical instance and client applications.
|
||||
|
||||
## Subcommands & flags
|
||||
|
||||
<Accordion title="infisical gateway" defaultOpen="true">
|
||||
Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection.
|
||||
|
||||
```bash
|
||||
infisical gateway --token=<token> --domain=<domain>
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
<Accordion title="--token">
|
||||
The machine identity access token to authenticate with Infisical.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
infisical gateway --token=<token>
|
||||
```
|
||||
|
||||
You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the gateway command.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--domain">
|
||||
Domain of your self-hosted Infisical instance.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
sudo infisical gateway install --domain=https://app.your-domain.com
|
||||
```
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="infisical gateway install">
|
||||
Install and enable the gateway as a systemd service. This command must be run with sudo on Linux.
|
||||
|
||||
```bash
|
||||
sudo infisical gateway install --token=<token> --domain=<domain>
|
||||
```
|
||||
|
||||
### Requirements
|
||||
- Must be run on Linux
|
||||
- Must be run with root/sudo privileges
|
||||
- Requires systemd
|
||||
|
||||
### Flags
|
||||
|
||||
<Accordion title="--token">
|
||||
The machine identity access token to authenticate with Infisical.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
sudo infisical gateway install --token=<token>
|
||||
```
|
||||
|
||||
You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--domain">
|
||||
Domain of your self-hosted Infisical instance.
|
||||
|
||||
```bash
|
||||
# Example
|
||||
sudo infisical gateway install --domain=https://app.your-domain.com
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
### Service Details
|
||||
The systemd service is installed with secure defaults:
|
||||
- Service file: `/etc/systemd/system/infisical-gateway.service`
|
||||
- Config file: `/etc/infisical/gateway.conf`
|
||||
- Runs with restricted privileges:
|
||||
- InaccessibleDirectories=/home
|
||||
- PrivateTmp=yes
|
||||
- Resource limits configured for stability
|
||||
- Automatically restarts on failure
|
||||
- Enabled to start on boot
|
||||
|
||||
After installation, manage the service with standard systemd commands:
|
||||
```bash
|
||||
sudo systemctl start infisical-gateway # Start the service
|
||||
sudo systemctl stop infisical-gateway # Stop the service
|
||||
sudo systemctl status infisical-gateway # Check service status
|
||||
sudo systemctl disable infisical-gateway # Disable auto-start on boot
|
||||
```
|
||||
</Accordion>
|
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
|
||||
|
Binary file not shown.
After ![]() (image error) Size: 324 KiB |
@ -4,6 +4,8 @@ sidebarTitle: "Overview"
|
||||
description: "How to access private network resources from Infisical"
|
||||
---
|
||||
|
||||

|
||||
|
||||
The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment.
|
||||
This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases.
|
||||
Common use cases include generating dynamic credentials or rotating credentials for private databases.
|
||||
@ -45,19 +47,53 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t
|
||||
</Step>
|
||||
|
||||
<Step title="Deploy the Gateway">
|
||||
Use the Infisical CLI to deploy the Gateway. You can log in with your machine identity and start the Gateway in one command. The example below demonstrates how to deploy the Gateway using the Universal Auth method:
|
||||
```bash
|
||||
infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain)
|
||||
```
|
||||
Alternatively, if you already have the token, use it directly with the `--token` flag:
|
||||
```bash
|
||||
infisical gateway --token <your-machine-identity-token>
|
||||
```
|
||||
Or set it as an environment variable:
|
||||
```bash
|
||||
export INFISICAL_TOKEN=<your-machine-identity-token>
|
||||
infisical gateway
|
||||
```
|
||||
Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Production (systemd)">
|
||||
For production deployments on Linux, install the Gateway as a systemd service:
|
||||
```bash
|
||||
sudo infisical gateway install --token <your-machine-identity-token> --domain <your-infisical-domain>
|
||||
sudo systemctl start infisical-gateway
|
||||
```
|
||||
This will install and start the Gateway as a secure systemd service that:
|
||||
- Runs with restricted privileges:
|
||||
- Runs as root user (required for secure token management)
|
||||
- Restricted access to home directories
|
||||
- Private temporary directory
|
||||
- Automatically restarts on failure
|
||||
- Starts on system boot
|
||||
- Manages token and domain configuration securely in `/etc/infisical/gateway.conf`
|
||||
|
||||
<Warning>
|
||||
The install command requires:
|
||||
- Linux operating system
|
||||
- Root/sudo privileges
|
||||
- Systemd
|
||||
</Warning>
|
||||
</Tab>
|
||||
|
||||
<Tab title="Development (direct)">
|
||||
For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command:
|
||||
```bash
|
||||
infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain)
|
||||
```
|
||||
|
||||
Alternatively, if you already have the token, use it directly with the `--token` flag:
|
||||
```bash
|
||||
infisical gateway --token <your-machine-identity-token>
|
||||
```
|
||||
|
||||
Or set it as an environment variable:
|
||||
```bash
|
||||
export INFISICAL_TOKEN=<your-machine-identity-token>
|
||||
infisical gateway
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway).
|
||||
|
||||
<Note>
|
||||
Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical.
|
||||
</Note>
|
||||
@ -78,4 +114,3 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t
|
||||
Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
68
docs/documentation/platform/secret-scanning.mdx
Normal file
68
docs/documentation/platform/secret-scanning.mdx
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 'Secret Scanning'
|
||||
description: "Scan and prevent secret leaks in your code repositories"
|
||||
---
|
||||
|
||||
The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories.
|
||||
|
||||
To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes.
|
||||
|
||||
## Code Scanning
|
||||
|
||||

|
||||
|
||||
Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets.
|
||||
|
||||
If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it:
|
||||
- **Date**: When the risk was first detected.
|
||||
- **Secret Type**: Which type of secret was detected.
|
||||
- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change.
|
||||
|
||||
Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret.
|
||||
|
||||
<Tip>
|
||||
Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed.
|
||||
</Tip>
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
## Responding to Exposed Secrets
|
||||
|
||||
After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible.
|
||||
|
||||
You can mark the risk as `Resolved` by changing the status to one of the following states:
|
||||
- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner.
|
||||
- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed.
|
||||
- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## Ignoring Known Secrets
|
||||
If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below.
|
||||
|
||||
### infisical-scan:ignore
|
||||
|
||||
To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming.
|
||||
|
||||
```js example.js
|
||||
function helloWorld() {
|
||||
console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore
|
||||
}
|
||||
```
|
||||
|
||||
### .infisicalignore
|
||||
An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root.
|
||||
You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found.
|
||||
By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans.
|
||||
|
||||
```.ignore .infisicalignore
|
||||
bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147
|
||||
bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5
|
||||
1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148
|
||||
```
|
@ -36,3 +36,18 @@ If the signature in the header matches the signature that you generated, then yo
|
||||
"timestamp": ""
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "secrets.reminder-expired",
|
||||
"project": {
|
||||
"workspaceId": "the workspace id",
|
||||
"environment": "project environment",
|
||||
"secretPath": "project folder path",
|
||||
"secretName": "name of the secret",
|
||||
"secretId": "id of the secret",
|
||||
"reminderNote": "reminder note of the secret"
|
||||
},
|
||||
"timestamp": ""
|
||||
}
|
||||
```
|
||||
|
BIN
docs/images/platform/secret-scanning/exposed-secret.png
Normal file
BIN
docs/images/platform/secret-scanning/exposed-secret.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 138 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user