mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-18 23:27:37 +00:00
Compare commits
193 Commits
feat/add-p
...
shubham/en
Author | SHA1 | Date | |
---|---|---|---|
e5b7ebbabf | |||
9d46c269d4 | |||
c1ca2a6f8c | |||
9b6602a8e9 | |||
22db286dda | |||
9fd0373e39 | |||
62f92b0bfa | |||
abbef4fc44 | |||
34ca942f9d | |||
1acf25dd53 | |||
a0653883b6 | |||
f3a4c32e3a | |||
6a6fe3e202 | |||
8f4963839e | |||
4c06f134fb | |||
12d3632a03 | |||
c34c13887a | |||
378d6c259b | |||
2a538d9560 | |||
eafa50747b | |||
77f794e1d0 | |||
3b9afb8b5b | |||
8bf763dd5a | |||
e93b465004 | |||
000d87075b | |||
2291bdc036 | |||
791361d2c3 | |||
2a18844ef8 | |||
1dfad876cf | |||
7ddf4492a7 | |||
3c92a2a256 | |||
45683dc4c6 | |||
c6043568cf | |||
cf690e2e16 | |||
c67642786f | |||
41914e0027 | |||
a13d4a4970 | |||
5db6ac711c | |||
f426025fd5 | |||
d6fcba9169 | |||
51d4fcf9ee | |||
316259f218 | |||
7311cf8f6c | |||
5560c18a09 | |||
b0c472b5e1 | |||
25a615cbb3 | |||
4502d394a3 | |||
531d3751a8 | |||
2d0d90785f | |||
cec884ce34 | |||
346dbee96a | |||
d5229a27b2 | |||
a11f120a83 | |||
51c1487ed1 | |||
c9d6c5c5f7 | |||
3541ddf8ac | |||
0ae286a80e | |||
36b7911bcc | |||
520167a8ff | |||
8c2f709f2a | |||
804314cc18 | |||
0c9557b8b5 | |||
fb4f12fa37 | |||
29b106c5bd | |||
e7d32b5f2d | |||
862e0437e7 | |||
89eff65124 | |||
2347242776 | |||
3438dbc70d | |||
d79d7ca5e8 | |||
c097c918ed | |||
b801c1e48f | |||
cd2b81cb9f | |||
bdd65784a1 | |||
73195b07a4 | |||
bdff2cd33d | |||
1990ce8c7d | |||
285c4a93c6 | |||
bbb21c95f6 | |||
394340c599 | |||
30039b97b5 | |||
71d4935c0f | |||
40e7ab33cb | |||
aa193adf48 | |||
dbac4b4567 | |||
df38e79590 | |||
8f778403b4 | |||
686a28cc09 | |||
1068e6024d | |||
286426b240 | |||
b5b778e241 | |||
f85a35fde8 | |||
3b40f37f50 | |||
4e51a3b784 | |||
387981ea87 | |||
81b0c8bc12 | |||
06dca77be2 | |||
b79ed28bb8 | |||
7c6b6653f5 | |||
6055661515 | |||
f3eda1fd13 | |||
60178a6ba6 | |||
3e6d43e4df | |||
f11c2d6b3e | |||
be68ecc25d | |||
b2ad7cc7c0 | |||
6c6c436cc6 | |||
01ea41611b | |||
dc7bf9674a | |||
b6814b67b0 | |||
5234a89612 | |||
45bb2f0fcc | |||
4c7e218d0d | |||
0371a57548 | |||
7d0eb9a0fd | |||
44b14756b1 | |||
1a4f8b23ff | |||
51f4047207 | |||
a618e0ebf2 | |||
4567e505ec | |||
c638caede5 | |||
300deb5607 | |||
3e9ce79398 | |||
0fc4fb8858 | |||
1e63604f1e | |||
6ce86c4240 | |||
fd65936ae7 | |||
c894a18797 | |||
c170ba6249 | |||
c344330c93 | |||
a6dd36f684 | |||
eb8acba037 | |||
c7a8e1102e | |||
aca71a7b6f | |||
ae075df0ec | |||
75927f711c | |||
b1b1ce07a3 | |||
fe4cc950d3 | |||
81f7884d03 | |||
b8c35fbf15 | |||
42e73d66fc | |||
a0f678a295 | |||
fe40e4f475 | |||
b9782c1a85 | |||
a0be2985dd | |||
86d16c5b9f | |||
c1c1471439 | |||
3639a7fc18 | |||
59c8dc3cda | |||
527e1d6b79 | |||
3e32915a82 | |||
7a955e3fae | |||
ee5130f56c | |||
719f3beab0 | |||
4faa9ced04 | |||
b6ff07b605 | |||
1753cd76be | |||
f75fc54e10 | |||
b9a6f94eea | |||
966bd77234 | |||
c782df1176 | |||
c0daa11aeb | |||
9b2b6d61be | |||
e9c5b7f846 | |||
008b37c0f4 | |||
c9b234dbea | |||
8497182a7b | |||
133841c322 | |||
3d65d121c0 | |||
a0d9331e67 | |||
8ec8b1ce2f | |||
e3dae9d498 | |||
41d72d5dc6 | |||
75bb651b1d | |||
4e06fa3a0c | |||
0f827fc31a | |||
7189544705 | |||
a724ab101c | |||
dea67e3cb0 | |||
ce66cccd8b | |||
91eda2419a | |||
b350eef2b9 | |||
85725215f2 | |||
76c9d642a9 | |||
3ed5dd6109 | |||
08e7815ec1 | |||
04d961b832 | |||
9c0a1b7089 | |||
9352e8bca0 | |||
265932df20 | |||
f23056bcbc | |||
fdf5fcad0a | |||
a85c59e3e2 |
.github/workflows
Dockerfile.standalone-infisicalbackend
e2e-test/mocks
package-lock.jsonpackage.jsonscripts
src
@types
db
migrations
20240527073740_identity-azure-auth.ts20240528153905_add-user-account-mfa-locking.ts20240528190137_secret_sharing.ts20240529060752_snap-shot-secret-index-secretversionid.ts20240529203152_secret_sharing.ts20240530044702_universal-text-in-secret-sharing.ts20240531220007_secret-replication.ts20240603075514_kms.ts
schemas
ee
routes/v1
identity-project-additional-privilege-router.tsorg-role-router.tsproject-role-router.tssecret-approval-request-router.ts
services
audit-log
license
secret-approval-request
secret-replication
secret-replication-constants.tssecret-replication-dal.tssecret-replication-service.tssecret-replication-types.ts
secret-snapshot
keystore
lib
api-docs
crypto
knex
red-lock
zod
queue
server
config
plugins
routes
services
auth-token
auth
identity-access-token
identity-azure-auth
identity-azure-auth-dal.tsidentity-azure-auth-fns.tsidentity-azure-auth-service.tsidentity-azure-auth-types.tsidentity-azure-auth-validators.ts
identity-project
integration-auth
integration
kms
project-role
resource-cleanup
secret-folder
secret-import
secret-sharing
secret
smtp
user
cli/packages
company
docs
api-reference/endpoints/project-roles
cli/commands
documentation
guides
platform
images
integrations/rundeck
integrations-rundeck-auth.pngintegrations-rundeck-create.pngintegrations-rundeck-token.pngintegrations-rundeck.png
platform
integrations
mint.jsonsdks/languages
self-hosting/deployment-options
style.cssfrontend
public
src
components/v2/Select
const.tshelpers
hooks/api
auth
identities
identityProjectAdditionalPrivilege
integrationAuth
integrations
roles
secretApprovalRequest
secretFolders
secretImports
secretSharing
layouts/AppLayout
lib/fn
pages
integrations
org/[id]/secret-sharing
shared/secret/[id]
views
IntegrationsPage
Login/components
Org/MembersPage/components/OrgIdentityTab/components/IdentitySection
Project/MembersPage/components
GroupsTab/components/GroupsSection
IdentityTab/components
MemberListTab/MemberRoleForm
ProjectRoleListTab
SecretApprovalPage/components/SecretApprovalRequest
SecretMainPage/components
ActionBar
SecretDropzone
SecretImportListView
SecretListView
ShareSecretPage
ShareSecretPublicPage
helm-charts/secrets-operator
k8-operator/packages
standalone-entrypoint.sh@ -40,13 +40,14 @@ jobs:
|
||||
REDIS_URL: redis://172.17.0.1:6379
|
||||
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
|
||||
JWT_AUTH_SECRET: something-random
|
||||
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21.5'
|
||||
- name: Wait for container to be stable and check logs
|
||||
run: |
|
||||
SECONDS=0
|
||||
HEALTHY=0
|
||||
r HEALTHY=0
|
||||
while [ $SECONDS -lt 60 ]; do
|
||||
if docker ps | grep infisical-api | grep -q healthy; then
|
||||
echo "Container is healthy."
|
||||
@ -73,4 +74,4 @@ jobs:
|
||||
run: |
|
||||
docker-compose -f "docker-compose.dev.yml" down
|
||||
docker stop infisical-api
|
||||
docker remove infisical-api
|
||||
docker remove infisical-api
|
||||
|
@ -55,6 +55,7 @@ VOLUME /app/.next/cache/images
|
||||
COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts
|
||||
COPY --from=frontend-builder /app/public ./public
|
||||
RUN chown non-root-user:nodejs ./public/data
|
||||
|
||||
COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./
|
||||
COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@ -93,9 +94,18 @@ RUN mkdir frontend-build
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
RUN apk add --upgrade --no-cache ca-certificates
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 non-root-user
|
||||
|
||||
# Give non-root-user permission to update SSL certs
|
||||
RUN chown -R non-root-user /etc/ssl/certs
|
||||
RUN chown non-root-user /etc/ssl/certs/ca-certificates.crt
|
||||
RUN chmod -R u+rwx /etc/ssl/certs
|
||||
RUN chmod u+rw /etc/ssl/certs/ca-certificates.crt
|
||||
RUN chown non-root-user /usr/sbin/update-ca-certificates
|
||||
RUN chmod u+rx /usr/sbin/update-ca-certificates
|
||||
|
||||
## set pre baked keys
|
||||
ARG POSTHOG_API_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { Lock } from "@app/lib/red-lock";
|
||||
|
||||
export const mockKeyStore = (): TKeyStoreFactory => {
|
||||
const store: Record<string, string | number | Buffer> = {};
|
||||
@ -25,6 +26,12 @@ export const mockKeyStore = (): TKeyStoreFactory => {
|
||||
},
|
||||
incrementBy: async () => {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
acquireLock: () => {
|
||||
return Promise.resolve({
|
||||
release: () => {}
|
||||
}) as Promise<Lock>;
|
||||
},
|
||||
waitTillReady: async () => {}
|
||||
};
|
||||
};
|
||||
|
9
backend/package-lock.json
generated
9
backend/package-lock.json
generated
@ -51,7 +51,7 @@
|
||||
"libsodium-wrappers": "^0.7.13",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"ms": "^2.1.3",
|
||||
"mysql2": "^3.9.7",
|
||||
"mysql2": "^3.9.8",
|
||||
"nanoid": "^5.0.4",
|
||||
"nodemailer": "^6.9.9",
|
||||
"ora": "^7.0.1",
|
||||
@ -10290,9 +10290,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.9.7",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz",
|
||||
"integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz",
|
||||
"integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
|
@ -112,7 +112,7 @@
|
||||
"libsodium-wrappers": "^0.7.13",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"ms": "^2.1.3",
|
||||
"mysql2": "^3.9.7",
|
||||
"mysql2": "^3.9.8",
|
||||
"nanoid": "^5.0.4",
|
||||
"nodemailer": "^6.9.9",
|
||||
"ora": "^7.0.1",
|
||||
|
@ -35,6 +35,8 @@ const getZodPrimitiveType = (type: string) => {
|
||||
return "z.coerce.number()";
|
||||
case "text":
|
||||
return "z.string()";
|
||||
case "bytea":
|
||||
return "zodBuffer";
|
||||
default:
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
@ -96,10 +98,15 @@ const main = async () => {
|
||||
const columnNames = Object.keys(columns);
|
||||
|
||||
let schema = "";
|
||||
const zodImportSet = new Set<string>();
|
||||
for (let colNum = 0; colNum < columnNames.length; colNum++) {
|
||||
const columnName = columnNames[colNum];
|
||||
const colInfo = columns[columnName];
|
||||
let ztype = getZodPrimitiveType(colInfo.type);
|
||||
if (["zodBuffer"].includes(ztype)) {
|
||||
zodImportSet.add(ztype);
|
||||
}
|
||||
|
||||
// don't put optional on id
|
||||
if (colInfo.defaultValue && columnName !== "id") {
|
||||
const { defaultValue } = colInfo;
|
||||
@ -121,6 +128,8 @@ const main = async () => {
|
||||
.split("_")
|
||||
.reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, "");
|
||||
|
||||
const zodImports = Array.from(zodImportSet);
|
||||
|
||||
// the insert and update are changed to zod input type to use default cases
|
||||
writeFileSync(
|
||||
path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`),
|
||||
@ -131,6 +140,8 @@ const main = async () => {
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
${zodImports.length ? `import { ${zodImports.join(",")} } from \"@app/lib/zod\";` : ""}
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const ${pascalCase}Schema = z.object({${schema}});
|
||||
|
6
backend/src/@types/fastify.d.ts
vendored
6
backend/src/@types/fastify.d.ts
vendored
@ -33,6 +33,7 @@ import { TGroupProjectServiceFactory } from "@app/services/group-project/group-p
|
||||
import { TIdentityServiceFactory } from "@app/services/identity/identity-service";
|
||||
import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
|
||||
import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service";
|
||||
import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service";
|
||||
import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service";
|
||||
import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service";
|
||||
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
|
||||
@ -51,6 +52,8 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
|
||||
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
|
||||
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||
import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service";
|
||||
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
|
||||
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
|
||||
@ -106,6 +109,7 @@ declare module "fastify" {
|
||||
projectKey: TProjectKeyServiceFactory;
|
||||
projectRole: TProjectRoleServiceFactory;
|
||||
secret: TSecretServiceFactory;
|
||||
secretReplication: TSecretReplicationServiceFactory;
|
||||
secretTag: TSecretTagServiceFactory;
|
||||
secretImport: TSecretImportServiceFactory;
|
||||
projectBot: TProjectBotServiceFactory;
|
||||
@ -121,6 +125,7 @@ declare module "fastify" {
|
||||
identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory;
|
||||
identityGcpAuth: TIdentityGcpAuthServiceFactory;
|
||||
identityAwsAuth: TIdentityAwsAuthServiceFactory;
|
||||
identityAzureAuth: TIdentityAzureAuthServiceFactory;
|
||||
accessApprovalPolicy: TAccessApprovalPolicyServiceFactory;
|
||||
accessApprovalRequest: TAccessApprovalRequestServiceFactory;
|
||||
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
|
||||
@ -141,6 +146,7 @@ declare module "fastify" {
|
||||
dynamicSecretLease: TDynamicSecretLeaseServiceFactory;
|
||||
projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory;
|
||||
identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory;
|
||||
secretSharing: TSecretSharingServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
33
backend/src/@types/knex.d.ts
vendored
33
backend/src/@types/knex.d.ts
vendored
@ -62,6 +62,9 @@ import {
|
||||
TIdentityAwsAuths,
|
||||
TIdentityAwsAuthsInsert,
|
||||
TIdentityAwsAuthsUpdate,
|
||||
TIdentityAzureAuths,
|
||||
TIdentityAzureAuthsInsert,
|
||||
TIdentityAzureAuthsUpdate,
|
||||
TIdentityGcpAuths,
|
||||
TIdentityGcpAuthsInsert,
|
||||
TIdentityGcpAuthsUpdate,
|
||||
@ -95,6 +98,15 @@ import {
|
||||
TIntegrations,
|
||||
TIntegrationsInsert,
|
||||
TIntegrationsUpdate,
|
||||
TKmsKeys,
|
||||
TKmsKeysInsert,
|
||||
TKmsKeysUpdate,
|
||||
TKmsKeyVersions,
|
||||
TKmsKeyVersionsInsert,
|
||||
TKmsKeyVersionsUpdate,
|
||||
TKmsRootConfig,
|
||||
TKmsRootConfigInsert,
|
||||
TKmsRootConfigUpdate,
|
||||
TLdapConfigs,
|
||||
TLdapConfigsInsert,
|
||||
TLdapConfigsUpdate,
|
||||
@ -173,6 +185,9 @@ import {
|
||||
TSecretImports,
|
||||
TSecretImportsInsert,
|
||||
TSecretImportsUpdate,
|
||||
TSecretReferences,
|
||||
TSecretReferencesInsert,
|
||||
TSecretReferencesUpdate,
|
||||
TSecretRotationOutputs,
|
||||
TSecretRotationOutputsInsert,
|
||||
TSecretRotationOutputsUpdate,
|
||||
@ -183,6 +198,9 @@ import {
|
||||
TSecretScanningGitRisks,
|
||||
TSecretScanningGitRisksInsert,
|
||||
TSecretScanningGitRisksUpdate,
|
||||
TSecretSharing,
|
||||
TSecretSharingInsert,
|
||||
TSecretSharingUpdate,
|
||||
TSecretsInsert,
|
||||
TSecretSnapshotFolders,
|
||||
TSecretSnapshotFoldersInsert,
|
||||
@ -234,7 +252,6 @@ import {
|
||||
TWebhooksInsert,
|
||||
TWebhooksUpdate
|
||||
} from "@app/db/schemas";
|
||||
import { TSecretReferences, TSecretReferencesInsert, TSecretReferencesUpdate } from "@app/db/schemas/secret-references";
|
||||
|
||||
declare module "knex/types/tables" {
|
||||
interface Tables {
|
||||
@ -325,6 +342,7 @@ declare module "knex/types/tables" {
|
||||
TSecretFolderVersionsInsert,
|
||||
TSecretFolderVersionsUpdate
|
||||
>;
|
||||
[TableName.SecretSharing]: Knex.CompositeTableType<TSecretSharing, TSecretSharingInsert, TSecretSharingUpdate>;
|
||||
[TableName.SecretTag]: Knex.CompositeTableType<TSecretTags, TSecretTagsInsert, TSecretTagsUpdate>;
|
||||
[TableName.SecretImport]: Knex.CompositeTableType<TSecretImports, TSecretImportsInsert, TSecretImportsUpdate>;
|
||||
[TableName.Integration]: Knex.CompositeTableType<TIntegrations, TIntegrationsInsert, TIntegrationsUpdate>;
|
||||
@ -356,6 +374,11 @@ declare module "knex/types/tables" {
|
||||
TIdentityAwsAuthsInsert,
|
||||
TIdentityAwsAuthsUpdate
|
||||
>;
|
||||
[TableName.IdentityAzureAuth]: Knex.CompositeTableType<
|
||||
TIdentityAzureAuths,
|
||||
TIdentityAzureAuthsInsert,
|
||||
TIdentityAzureAuthsUpdate
|
||||
>;
|
||||
[TableName.IdentityUaClientSecret]: Knex.CompositeTableType<
|
||||
TIdentityUaClientSecrets,
|
||||
TIdentityUaClientSecretsInsert,
|
||||
@ -502,5 +525,13 @@ declare module "knex/types/tables" {
|
||||
TSecretVersionTagJunctionInsert,
|
||||
TSecretVersionTagJunctionUpdate
|
||||
>;
|
||||
// KMS service
|
||||
[TableName.KmsServerRootConfig]: Knex.CompositeTableType<
|
||||
TKmsRootConfig,
|
||||
TKmsRootConfigInsert,
|
||||
TKmsRootConfigUpdate
|
||||
>;
|
||||
[TableName.KmsKey]: Knex.CompositeTableType<TKmsKeys, TKmsKeysInsert, TKmsKeysUpdate>;
|
||||
[TableName.KmsKeyVersion]: Knex.CompositeTableType<TKmsKeyVersions, TKmsKeyVersionsInsert, TKmsKeyVersionsUpdate>;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.IdentityAzureAuth))) {
|
||||
await knex.schema.createTable(TableName.IdentityAzureAuth, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable();
|
||||
t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable();
|
||||
t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable();
|
||||
t.jsonb("accessTokenTrustedIps").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
t.uuid("identityId").notNullable().unique();
|
||||
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
|
||||
t.string("tenantId").notNullable();
|
||||
t.string("resource").notNullable();
|
||||
t.string("allowedServicePrincipalIds").notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.IdentityAzureAuth);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.IdentityAzureAuth);
|
||||
await dropOnUpdateTrigger(knex, TableName.IdentityAzureAuth);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts");
|
||||
const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked");
|
||||
const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd");
|
||||
|
||||
await knex.schema.alterTable(TableName.Users, (t) => {
|
||||
if (!hasConsecutiveFailedMfaAttempts) {
|
||||
t.integer("consecutiveFailedMfaAttempts").defaultTo(0);
|
||||
}
|
||||
|
||||
if (!hasIsLocked) {
|
||||
t.boolean("isLocked").defaultTo(false);
|
||||
}
|
||||
|
||||
if (!hasTemporaryLockDateEnd) {
|
||||
t.dateTime("temporaryLockDateEnd").nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts");
|
||||
const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked");
|
||||
const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd");
|
||||
|
||||
await knex.schema.alterTable(TableName.Users, (t) => {
|
||||
if (hasConsecutiveFailedMfaAttempts) {
|
||||
t.dropColumn("consecutiveFailedMfaAttempts");
|
||||
}
|
||||
|
||||
if (hasIsLocked) {
|
||||
t.dropColumn("isLocked");
|
||||
}
|
||||
|
||||
if (hasTemporaryLockDateEnd) {
|
||||
t.dropColumn("temporaryLockDateEnd");
|
||||
}
|
||||
});
|
||||
}
|
29
backend/src/db/migrations/20240528190137_secret_sharing.ts
Normal file
29
backend/src/db/migrations/20240528190137_secret_sharing.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.SecretSharing))) {
|
||||
await knex.schema.createTable(TableName.SecretSharing, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name").notNullable();
|
||||
t.text("encryptedValue").notNullable();
|
||||
t.text("iv").notNullable();
|
||||
t.text("tag").notNullable();
|
||||
t.text("hashedHex").notNullable();
|
||||
t.timestamp("expiresAt").notNullable();
|
||||
t.uuid("userId").notNullable();
|
||||
t.uuid("orgId").notNullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SecretSharing);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.SecretSharing);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId");
|
||||
if (await knex.schema.hasTable(TableName.SnapshotSecret)) {
|
||||
await knex.schema.alterTable(TableName.SnapshotSecret, (t) => {
|
||||
if (doesSecretVersionIdExist) t.index("secretVersionId");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId");
|
||||
if (await knex.schema.hasTable(TableName.SnapshotSecret)) {
|
||||
await knex.schema.alterTable(TableName.SnapshotSecret, (t) => {
|
||||
if (doesSecretVersionIdExist) t.dropIndex("secretVersionId");
|
||||
});
|
||||
}
|
||||
}
|
29
backend/src/db/migrations/20240529203152_secret_sharing.ts
Normal file
29
backend/src/db/migrations/20240529203152_secret_sharing.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.SecretSharing))) {
|
||||
await knex.schema.createTable(TableName.SecretSharing, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name").notNullable();
|
||||
t.text("encryptedValue").notNullable();
|
||||
t.text("iv").notNullable();
|
||||
t.text("tag").notNullable();
|
||||
t.text("hashedHex").notNullable();
|
||||
t.timestamp("expiresAt").notNullable();
|
||||
t.uuid("userId").notNullable();
|
||||
t.uuid("orgId").notNullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SecretSharing);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.SecretSharing);
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
|
||||
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||
if (!hasExpiresAfterViewsColumn) {
|
||||
t.integer("expiresAfterViews");
|
||||
}
|
||||
|
||||
if (hasSecretNameColumn) {
|
||||
t.dropColumn("name");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
|
||||
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||
if (hasExpiresAfterViewsColumn) {
|
||||
t.dropColumn("expiresAfterViews");
|
||||
}
|
||||
|
||||
if (!hasSecretNameColumn) {
|
||||
t.string("name").notNullable();
|
||||
}
|
||||
});
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication");
|
||||
const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn(
|
||||
TableName.SecretImport,
|
||||
"isReplicationSuccess"
|
||||
);
|
||||
const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn(
|
||||
TableName.SecretImport,
|
||||
"replicationStatus"
|
||||
);
|
||||
const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated");
|
||||
const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved");
|
||||
|
||||
if (await knex.schema.hasTable(TableName.SecretImport)) {
|
||||
await knex.schema.alterTable(TableName.SecretImport, (t) => {
|
||||
if (!doesSecretImportIsReplicationExist) t.boolean("isReplication").defaultTo(false);
|
||||
if (!doesSecretImportIsReplicationSuccessExist) t.boolean("isReplicationSuccess").nullable();
|
||||
if (!doesSecretImportReplicationStatusExist) t.text("replicationStatus").nullable();
|
||||
if (!doesSecretImportLastReplicatedExist) t.datetime("lastReplicated").nullable();
|
||||
if (!doesSecretImportIsReservedExist) t.boolean("isReserved").defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved");
|
||||
if (await knex.schema.hasTable(TableName.SecretFolder)) {
|
||||
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
|
||||
if (!doesSecretFolderReservedExist) t.boolean("isReserved").defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn(
|
||||
TableName.SecretApprovalRequest,
|
||||
"isReplicated"
|
||||
);
|
||||
if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => {
|
||||
if (!doesSecretApprovalRequestIsReplicatedExist) t.boolean("isReplicated");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication");
|
||||
const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn(
|
||||
TableName.SecretImport,
|
||||
"isReplicationSuccess"
|
||||
);
|
||||
const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn(
|
||||
TableName.SecretImport,
|
||||
"replicationStatus"
|
||||
);
|
||||
const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated");
|
||||
const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved");
|
||||
|
||||
if (await knex.schema.hasTable(TableName.SecretImport)) {
|
||||
await knex.schema.alterTable(TableName.SecretImport, (t) => {
|
||||
if (doesSecretImportIsReplicationExist) t.dropColumn("isReplication");
|
||||
if (doesSecretImportIsReplicationSuccessExist) t.dropColumn("isReplicationSuccess");
|
||||
if (doesSecretImportReplicationStatusExist) t.dropColumn("replicationStatus");
|
||||
if (doesSecretImportLastReplicatedExist) t.dropColumn("lastReplicated");
|
||||
if (doesSecretImportIsReservedExist) t.dropColumn("isReserved");
|
||||
});
|
||||
}
|
||||
|
||||
const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved");
|
||||
if (await knex.schema.hasTable(TableName.SecretFolder)) {
|
||||
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
|
||||
if (doesSecretFolderReservedExist) t.dropColumn("isReserved");
|
||||
});
|
||||
}
|
||||
|
||||
const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn(
|
||||
TableName.SecretApprovalRequest,
|
||||
"isReplicated"
|
||||
);
|
||||
if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => {
|
||||
if (doesSecretApprovalRequestIsReplicatedExist) t.dropColumn("isReplicated");
|
||||
});
|
||||
}
|
||||
}
|
56
backend/src/db/migrations/20240603075514_kms.ts
Normal file
56
backend/src/db/migrations/20240603075514_kms.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.KmsServerRootConfig))) {
|
||||
await knex.schema.createTable(TableName.KmsServerRootConfig, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.binary("encryptedRootKey").notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.KmsServerRootConfig);
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.KmsKey))) {
|
||||
await knex.schema.createTable(TableName.KmsKey, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.binary("encryptedKey").notNullable();
|
||||
t.string("encryptionAlgorithm").notNullable();
|
||||
t.integer("version").defaultTo(1).notNullable();
|
||||
t.string("description");
|
||||
t.boolean("isDisabled").defaultTo(false);
|
||||
t.boolean("isReserved").defaultTo(true);
|
||||
t.string("projectId");
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.uuid("orgId");
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.KmsKey);
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.KmsKeyVersion))) {
|
||||
await knex.schema.createTable(TableName.KmsKeyVersion, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.binary("encryptedKey").notNullable();
|
||||
t.integer("version").notNullable();
|
||||
t.uuid("kmsKeyId").notNullable();
|
||||
t.foreign("kmsKeyId").references("id").inTable(TableName.KmsKey).onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.KmsKeyVersion);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.KmsServerRootConfig);
|
||||
await dropOnUpdateTrigger(knex, TableName.KmsServerRootConfig);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.KmsKeyVersion);
|
||||
await dropOnUpdateTrigger(knex, TableName.KmsKeyVersion);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.KmsKey);
|
||||
await dropOnUpdateTrigger(knex, TableName.KmsKey);
|
||||
}
|
26
backend/src/db/schemas/identity-azure-auths.ts
Normal file
26
backend/src/db/schemas/identity-azure-auths.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const IdentityAzureAuthsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
accessTokenTTL: z.coerce.number().default(7200),
|
||||
accessTokenMaxTTL: z.coerce.number().default(7200),
|
||||
accessTokenNumUsesLimit: z.coerce.number().default(0),
|
||||
accessTokenTrustedIps: z.unknown(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
identityId: z.string().uuid(),
|
||||
tenantId: z.string(),
|
||||
resource: z.string(),
|
||||
allowedServicePrincipalIds: z.string()
|
||||
});
|
||||
|
||||
export type TIdentityAzureAuths = z.infer<typeof IdentityAzureAuthsSchema>;
|
||||
export type TIdentityAzureAuthsInsert = Omit<z.input<typeof IdentityAzureAuthsSchema>, TImmutableDBKeys>;
|
||||
export type TIdentityAzureAuthsUpdate = Partial<Omit<z.input<typeof IdentityAzureAuthsSchema>, TImmutableDBKeys>>;
|
@ -18,6 +18,7 @@ export * from "./groups";
|
||||
export * from "./identities";
|
||||
export * from "./identity-access-tokens";
|
||||
export * from "./identity-aws-auths";
|
||||
export * from "./identity-azure-auths";
|
||||
export * from "./identity-gcp-auths";
|
||||
export * from "./identity-kubernetes-auths";
|
||||
export * from "./identity-org-memberships";
|
||||
@ -29,6 +30,9 @@ export * from "./identity-universal-auths";
|
||||
export * from "./incident-contacts";
|
||||
export * from "./integration-auths";
|
||||
export * from "./integrations";
|
||||
export * from "./kms-key-versions";
|
||||
export * from "./kms-keys";
|
||||
export * from "./kms-root-config";
|
||||
export * from "./ldap-configs";
|
||||
export * from "./ldap-group-maps";
|
||||
export * from "./models";
|
||||
@ -56,9 +60,11 @@ export * from "./secret-blind-indexes";
|
||||
export * from "./secret-folder-versions";
|
||||
export * from "./secret-folders";
|
||||
export * from "./secret-imports";
|
||||
export * from "./secret-references";
|
||||
export * from "./secret-rotation-outputs";
|
||||
export * from "./secret-rotations";
|
||||
export * from "./secret-scanning-git-risks";
|
||||
export * from "./secret-sharing";
|
||||
export * from "./secret-snapshot-folders";
|
||||
export * from "./secret-snapshot-secrets";
|
||||
export * from "./secret-snapshots";
|
||||
|
21
backend/src/db/schemas/kms-key-versions.ts
Normal file
21
backend/src/db/schemas/kms-key-versions.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const KmsKeyVersionsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedKey: zodBuffer,
|
||||
version: z.number(),
|
||||
kmsKeyId: z.string().uuid()
|
||||
});
|
||||
|
||||
export type TKmsKeyVersions = z.infer<typeof KmsKeyVersionsSchema>;
|
||||
export type TKmsKeyVersionsInsert = Omit<z.input<typeof KmsKeyVersionsSchema>, TImmutableDBKeys>;
|
||||
export type TKmsKeyVersionsUpdate = Partial<Omit<z.input<typeof KmsKeyVersionsSchema>, TImmutableDBKeys>>;
|
26
backend/src/db/schemas/kms-keys.ts
Normal file
26
backend/src/db/schemas/kms-keys.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const KmsKeysSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedKey: zodBuffer,
|
||||
encryptionAlgorithm: z.string(),
|
||||
version: z.number().default(1),
|
||||
description: z.string().nullable().optional(),
|
||||
isDisabled: z.boolean().default(false).nullable().optional(),
|
||||
isReserved: z.boolean().default(true).nullable().optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
orgId: z.string().uuid().nullable().optional()
|
||||
});
|
||||
|
||||
export type TKmsKeys = z.infer<typeof KmsKeysSchema>;
|
||||
export type TKmsKeysInsert = Omit<z.input<typeof KmsKeysSchema>, TImmutableDBKeys>;
|
||||
export type TKmsKeysUpdate = Partial<Omit<z.input<typeof KmsKeysSchema>, TImmutableDBKeys>>;
|
19
backend/src/db/schemas/kms-root-config.ts
Normal file
19
backend/src/db/schemas/kms-root-config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const KmsRootConfigSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedRootKey: zodBuffer
|
||||
});
|
||||
|
||||
export type TKmsRootConfig = z.infer<typeof KmsRootConfigSchema>;
|
||||
export type TKmsRootConfigInsert = Omit<z.input<typeof KmsRootConfigSchema>, TImmutableDBKeys>;
|
||||
export type TKmsRootConfigUpdate = Partial<Omit<z.input<typeof KmsRootConfigSchema>, TImmutableDBKeys>>;
|
@ -29,6 +29,7 @@ export enum TableName {
|
||||
ProjectKeys = "project_keys",
|
||||
Secret = "secrets",
|
||||
SecretReference = "secret_references",
|
||||
SecretSharing = "secret_sharing",
|
||||
SecretBlindIndex = "secret_blind_indexes",
|
||||
SecretVersion = "secret_versions",
|
||||
SecretFolder = "secret_folders",
|
||||
@ -47,6 +48,7 @@ export enum TableName {
|
||||
IdentityUniversalAuth = "identity_universal_auths",
|
||||
IdentityKubernetesAuth = "identity_kubernetes_auths",
|
||||
IdentityGcpAuth = "identity_gcp_auths",
|
||||
IdentityAzureAuth = "identity_azure_auths",
|
||||
IdentityUaClientSecret = "identity_ua_client_secrets",
|
||||
IdentityAwsAuth = "identity_aws_auths",
|
||||
IdentityOrgMembership = "identity_org_memberships",
|
||||
@ -79,7 +81,11 @@ export enum TableName {
|
||||
DynamicSecretLease = "dynamic_secret_leases",
|
||||
// junction tables with tags
|
||||
JnSecretTag = "secret_tag_junction",
|
||||
SecretVersionTag = "secret_version_tag_junction"
|
||||
SecretVersionTag = "secret_version_tag_junction",
|
||||
// KMS Service
|
||||
KmsServerRootConfig = "kms_root_config",
|
||||
KmsKey = "kms_keys",
|
||||
KmsKeyVersion = "kms_key_versions"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
|
||||
@ -149,5 +155,6 @@ export enum IdentityAuthMethod {
|
||||
Univeral = "universal-auth",
|
||||
KUBERNETES_AUTH = "kubernetes-auth",
|
||||
GCP_AUTH = "gcp-auth",
|
||||
AWS_AUTH = "aws-auth"
|
||||
AWS_AUTH = "aws-auth",
|
||||
AZURE_AUTH = "azure-auth"
|
||||
}
|
||||
|
@ -18,7 +18,8 @@ export const SecretApprovalRequestsSchema = z.object({
|
||||
statusChangeBy: z.string().uuid().nullable().optional(),
|
||||
committerId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
isReplicated: z.boolean().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
|
||||
|
@ -14,7 +14,8 @@ export const SecretFoldersSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
envId: z.string().uuid(),
|
||||
parentId: z.string().uuid().nullable().optional()
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
isReserved: z.boolean().default(false).nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;
|
||||
|
@ -15,7 +15,12 @@ export const SecretImportsSchema = z.object({
|
||||
position: z.number(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
folderId: z.string().uuid()
|
||||
folderId: z.string().uuid(),
|
||||
isReplication: z.boolean().default(false).nullable().optional(),
|
||||
isReplicationSuccess: z.boolean().nullable().optional(),
|
||||
replicationStatus: z.string().nullable().optional(),
|
||||
lastReplicated: z.date().nullable().optional(),
|
||||
isReserved: z.boolean().default(false).nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretImports = z.infer<typeof SecretImportsSchema>;
|
||||
|
26
backend/src/db/schemas/secret-sharing.ts
Normal file
26
backend/src/db/schemas/secret-sharing.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SecretSharingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.date(),
|
||||
userId: z.string().uuid(),
|
||||
orgId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
expiresAfterViews: z.number().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||
export type TSecretSharingInsert = Omit<z.input<typeof SecretSharingSchema>, TImmutableDBKeys>;
|
||||
export type TSecretSharingUpdate = Partial<Omit<z.input<typeof SecretSharingSchema>, TImmutableDBKeys>>;
|
@ -22,7 +22,10 @@ export const UsersSchema = z.object({
|
||||
updatedAt: z.date(),
|
||||
isGhost: z.boolean().default(false),
|
||||
username: z.string(),
|
||||
isEmailVerified: z.boolean().default(false).nullable().optional()
|
||||
isEmailVerified: z.boolean().default(false).nullable().optional(),
|
||||
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
|
||||
isLocked: z.boolean().default(false).nullable().optional(),
|
||||
temporaryLockDateEnd: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TUsers = z.infer<typeof UsersSchema>;
|
||||
|
@ -5,10 +5,15 @@ import { z } from "zod";
|
||||
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types";
|
||||
import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ProjectPermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import {
|
||||
ProjectPermissionSchema,
|
||||
ProjectSpecificPrivilegePermissionSchema,
|
||||
SanitizedIdentityPrivilegeSchema
|
||||
} from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
|
||||
@ -39,7 +44,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
})
|
||||
.optional()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
permissions: ProjectPermissionSchema.array()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
.optional(),
|
||||
privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe(
|
||||
IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission
|
||||
).optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -49,6 +59,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { permissions, privilegePermission } = req.body;
|
||||
if (!permissions && !privilegePermission) {
|
||||
throw new BadRequestError({ message: "Permission or privilegePermission must be provided" });
|
||||
}
|
||||
|
||||
const permission = privilegePermission
|
||||
? privilegePermission.actions.map((action) => ({
|
||||
action,
|
||||
subject: privilegePermission.subject,
|
||||
conditions: privilegePermission.conditions
|
||||
}))
|
||||
: permissions!;
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
@ -57,7 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
...req.body,
|
||||
slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)),
|
||||
isTemporary: false,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
permissions: JSON.stringify(packRules(permission))
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
@ -90,7 +112,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
})
|
||||
.optional()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
|
||||
permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions),
|
||||
permissions: ProjectPermissionSchema.array()
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions)
|
||||
.optional(),
|
||||
privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe(
|
||||
IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission
|
||||
).optional(),
|
||||
temporaryMode: z
|
||||
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
|
||||
@ -111,6 +138,19 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { permissions, privilegePermission } = req.body;
|
||||
if (!permissions && !privilegePermission) {
|
||||
throw new BadRequestError({ message: "Permission or privilegePermission must be provided" });
|
||||
}
|
||||
|
||||
const permission = privilegePermission
|
||||
? privilegePermission.actions.map((action) => ({
|
||||
action,
|
||||
subject: privilegePermission.subject,
|
||||
conditions: privilegePermission.conditions
|
||||
}))
|
||||
: permissions!;
|
||||
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
@ -119,7 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
...req.body,
|
||||
slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)),
|
||||
isTemporary: true,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
permissions: JSON.stringify(packRules(permission))
|
||||
});
|
||||
return { privilege };
|
||||
}
|
||||
@ -156,13 +196,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
})
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug),
|
||||
permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
|
||||
privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe(
|
||||
IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission
|
||||
).optional(),
|
||||
isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
|
||||
temporaryMode: z
|
||||
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode),
|
||||
temporaryRange: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
|
||||
.refine((val) => typeof val === "undefined" || ms(val) > 0, "Temporary range must be a positive number")
|
||||
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange),
|
||||
temporaryAccessStartTime: z
|
||||
.string()
|
||||
@ -179,7 +222,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const updatedInfo = req.body.privilegeDetails;
|
||||
const { permissions, privilegePermission, ...updatedInfo } = req.body.privilegeDetails;
|
||||
if (!permissions && !privilegePermission) {
|
||||
throw new BadRequestError({ message: "Permission or privilegePermission must be provided" });
|
||||
}
|
||||
|
||||
const permission = privilegePermission
|
||||
? privilegePermission.actions.map((action) => ({
|
||||
action,
|
||||
subject: privilegePermission.subject,
|
||||
conditions: privilegePermission.conditions
|
||||
}))
|
||||
: permissions!;
|
||||
const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
@ -190,7 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
|
||||
projectSlug: req.body.projectSlug,
|
||||
data: {
|
||||
...updatedInfo,
|
||||
permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined
|
||||
permissions: permission ? JSON.stringify(packRules(permission)) : undefined
|
||||
}
|
||||
});
|
||||
return { privilege };
|
||||
|
@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
||||
.min(1)
|
||||
.trim()
|
||||
.refine(
|
||||
(val) => !Object.keys(OrgMembershipRole).includes(val),
|
||||
(val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole),
|
||||
"Please choose a different slug, the slug you have entered is reserved"
|
||||
)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
|
@ -1,146 +1,232 @@
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas";
|
||||
import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas";
|
||||
import { PROJECT_ROLE } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:projectId/roles",
|
||||
url: "/:projectSlug/roles",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create a project role",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectId: z.string().trim()
|
||||
projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug)
|
||||
}),
|
||||
body: z.object({
|
||||
slug: z.string().trim(),
|
||||
name: z.string().trim(),
|
||||
description: z.string().trim().optional(),
|
||||
permissions: z.any().array()
|
||||
slug: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(
|
||||
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
|
||||
"Please choose a different slug, the slug you have entered is reserved"
|
||||
)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid"
|
||||
})
|
||||
.describe(PROJECT_ROLE.CREATE.slug),
|
||||
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
|
||||
description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description),
|
||||
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
role: ProjectRolesSchema
|
||||
role: SanitizedRoleSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const role = await server.services.projectRole.createRole(
|
||||
req.permission.type,
|
||||
req.permission.id,
|
||||
req.params.projectId,
|
||||
req.body,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
const role = await server.services.projectRole.createRole({
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
projectSlug: req.params.projectSlug,
|
||||
data: {
|
||||
...req.body,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
}
|
||||
});
|
||||
return { role };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:projectId/roles/:roleId",
|
||||
url: "/:projectSlug/roles/:roleId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update a project role",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectId: z.string().trim(),
|
||||
roleId: z.string().trim()
|
||||
projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug),
|
||||
roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId)
|
||||
}),
|
||||
body: z.object({
|
||||
slug: z.string().trim().optional(),
|
||||
name: z.string().trim().optional(),
|
||||
description: z.string().trim().optional(),
|
||||
permissions: z.any().array()
|
||||
slug: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.optional()
|
||||
.describe(PROJECT_ROLE.UPDATE.slug)
|
||||
.refine(
|
||||
(val) =>
|
||||
typeof val === "undefined" ||
|
||||
!Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
|
||||
"Please choose a different slug, the slug you have entered is reserved"
|
||||
)
|
||||
.refine((val) => typeof val === "undefined" || slugify(val) === val, {
|
||||
message: "Slug must be a valid"
|
||||
}),
|
||||
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
|
||||
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
role: ProjectRolesSchema
|
||||
role: SanitizedRoleSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const role = await server.services.projectRole.updateRole(
|
||||
req.permission.type,
|
||||
req.permission.id,
|
||||
req.params.projectId,
|
||||
req.params.roleId,
|
||||
req.body,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
const role = await server.services.projectRole.updateRole({
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
projectSlug: req.params.projectSlug,
|
||||
roleId: req.params.roleId,
|
||||
data: {
|
||||
...req.body,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
}
|
||||
});
|
||||
return { role };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:projectId/roles/:roleId",
|
||||
url: "/:projectSlug/roles/:roleId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete a project role",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectId: z.string().trim(),
|
||||
roleId: z.string().trim()
|
||||
projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug),
|
||||
roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
role: ProjectRolesSchema
|
||||
role: SanitizedRoleSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const role = await server.services.projectRole.deleteRole(
|
||||
req.permission.type,
|
||||
req.permission.id,
|
||||
req.params.projectId,
|
||||
req.params.roleId,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
const role = await server.services.projectRole.deleteRole({
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
projectSlug: req.params.projectSlug,
|
||||
roleId: req.params.roleId
|
||||
});
|
||||
return { role };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectId/roles",
|
||||
url: "/:projectSlug/roles",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List project role",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
roles: ProjectRolesSchema.omit({ permissions: true }).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const roles = await server.services.projectRole.listRoles({
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
projectSlug: req.params.projectSlug
|
||||
});
|
||||
return { roles };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectSlug/roles/slug/:slug",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
projectId: z.string().trim()
|
||||
projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug),
|
||||
slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
data: z.object({
|
||||
roles: ProjectRolesSchema.omit({ permissions: true })
|
||||
.merge(z.object({ permissions: z.unknown() }))
|
||||
.array()
|
||||
})
|
||||
role: SanitizedRoleSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const roles = await server.services.projectRole.listRoles(
|
||||
req.permission.type,
|
||||
req.permission.id,
|
||||
req.params.projectId,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
return { data: { roles } };
|
||||
const role = await server.services.projectRole.getRoleBySlug({
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
projectSlug: req.params.projectSlug,
|
||||
roleSlug: req.params.slug
|
||||
});
|
||||
return { role };
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -32,22 +32,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approvals: SecretApprovalRequestsSchema.merge(
|
||||
z.object({
|
||||
// secretPath: z.string(),
|
||||
policy: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
approvers: z.string().array(),
|
||||
secretPath: z.string().optional().nullable()
|
||||
}),
|
||||
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
|
||||
environment: z.string(),
|
||||
reviewers: z.object({ member: z.string(), status: z.string() }).array(),
|
||||
approvers: z.string().array()
|
||||
})
|
||||
).array()
|
||||
approvals: SecretApprovalRequestsSchema.extend({
|
||||
// secretPath: z.string(),
|
||||
policy: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
approvers: z.string().array(),
|
||||
secretPath: z.string().optional().nullable()
|
||||
}),
|
||||
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
|
||||
environment: z.string(),
|
||||
reviewers: z.object({ member: z.string(), status: z.string() }).array(),
|
||||
approvers: z.string().array()
|
||||
}).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -3,7 +3,6 @@ import { RawAxiosRequestHeaders } from "axios";
|
||||
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
@ -113,35 +112,7 @@ export const auditLogQueueServiceFactory = ({
|
||||
);
|
||||
});
|
||||
|
||||
queueService.start(QueueName.AuditLogPrune, async () => {
|
||||
logger.info(`${QueueName.AuditLogPrune}: queue task started`);
|
||||
await auditLogDAL.pruneAuditLog();
|
||||
logger.info(`${QueueName.AuditLogPrune}: queue task completed`);
|
||||
});
|
||||
|
||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
||||
const startAuditLogPruneJob = async () => {
|
||||
// clear previous job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.AuditLogPrune,
|
||||
QueueJobs.AuditLogPrune,
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.AuditLogPrune // just a job id
|
||||
);
|
||||
|
||||
await queueService.queue(QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, undefined, {
|
||||
delay: 5000,
|
||||
jobId: QueueName.AuditLogPrune,
|
||||
repeat: { pattern: "0 0 * * *", utc: true }
|
||||
});
|
||||
};
|
||||
|
||||
queueService.listen(QueueName.AuditLogPrune, "failed", (err) => {
|
||||
logger.error(err?.failedReason, `${QueueName.AuditLogPrune}: log pruning failed`);
|
||||
});
|
||||
|
||||
return {
|
||||
pushToLog,
|
||||
startAuditLogPruneJob
|
||||
pushToLog
|
||||
};
|
||||
};
|
||||
|
@ -79,6 +79,10 @@ export enum EventType {
|
||||
ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth",
|
||||
UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth",
|
||||
GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth",
|
||||
LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth",
|
||||
ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth",
|
||||
UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth",
|
||||
GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth",
|
||||
CREATE_ENVIRONMENT = "create-environment",
|
||||
UPDATE_ENVIRONMENT = "update-environment",
|
||||
DELETE_ENVIRONMENT = "delete-environment",
|
||||
@ -572,6 +576,48 @@ interface GetIdentityAwsAuthEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginIdentityAzureAuthEvent {
|
||||
type: EventType.LOGIN_IDENTITY_AZURE_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
identityAzureAuthId: string;
|
||||
identityAccessTokenId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddIdentityAzureAuthEvent {
|
||||
type: EventType.ADD_IDENTITY_AZURE_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
tenantId: string;
|
||||
resource: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateIdentityAzureAuthEvent {
|
||||
type: EventType.UPDATE_IDENTITY_AZURE_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
tenantId?: string;
|
||||
resource?: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetIdentityAzureAuthEvent {
|
||||
type: EventType.GET_IDENTITY_AZURE_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateEnvironmentEvent {
|
||||
type: EventType.CREATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
@ -839,6 +885,10 @@ export type Event =
|
||||
| AddIdentityAwsAuthEvent
|
||||
| UpdateIdentityAwsAuthEvent
|
||||
| GetIdentityAwsAuthEvent
|
||||
| LoginIdentityAzureAuthEvent
|
||||
| AddIdentityAzureAuthEvent
|
||||
| UpdateIdentityAzureAuthEvent
|
||||
| GetIdentityAzureAuthEvent
|
||||
| CreateEnvironmentEvent
|
||||
| UpdateEnvironmentEvent
|
||||
| DeleteEnvironmentEvent
|
||||
|
@ -16,6 +16,8 @@ export const licenseDALFactory = (db: TDbClient) => {
|
||||
void bd.where({ orgId });
|
||||
}
|
||||
})
|
||||
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||
.where(`${TableName.Users}.isGhost`, false)
|
||||
.count();
|
||||
return doc?.[0].count;
|
||||
} catch (error) {
|
||||
|
@ -15,9 +15,16 @@ import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import { getAllNestedSecretReferences } from "@app/services/secret/secret-fns";
|
||||
import {
|
||||
fnSecretBlindIndexCheck,
|
||||
fnSecretBlindIndexCheckV2,
|
||||
fnSecretBulkDelete,
|
||||
fnSecretBulkInsert,
|
||||
fnSecretBulkUpdate,
|
||||
getAllNestedSecretReferences
|
||||
} from "@app/services/secret/secret-fns";
|
||||
import { TSecretQueueFactory } from "@app/services/secret/secret-queue";
|
||||
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { SecretOperations } from "@app/services/secret/secret-types";
|
||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||
@ -32,7 +39,6 @@ import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-requ
|
||||
import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal";
|
||||
import {
|
||||
ApprovalStatus,
|
||||
CommitType,
|
||||
RequestState,
|
||||
TApprovalRequestCountDTO,
|
||||
TGenerateSecretApprovalRequestDTO,
|
||||
@ -45,10 +51,11 @@ import {
|
||||
|
||||
type TSecretApprovalRequestServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
secretApprovalRequestDAL: TSecretApprovalRequestDALFactory;
|
||||
secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory;
|
||||
secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findById" | "findSecretPathByFolderIds">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findSecretPathByFolderIds">;
|
||||
secretDAL: TSecretDALFactory;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret">;
|
||||
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
|
||||
@ -56,16 +63,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
secretService: Pick<
|
||||
TSecretServiceFactory,
|
||||
| "fnSecretBulkInsert"
|
||||
| "fnSecretBulkUpdate"
|
||||
| "fnSecretBlindIndexCheck"
|
||||
| "fnSecretBulkDelete"
|
||||
| "fnSecretBlindIndexCheckV2"
|
||||
>;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
|
||||
@ -82,7 +80,6 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
projectDAL,
|
||||
permissionService,
|
||||
snapshotService,
|
||||
secretService,
|
||||
secretVersionDAL,
|
||||
secretQueueService,
|
||||
projectBotService
|
||||
@ -302,11 +299,12 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
|
||||
if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" });
|
||||
|
||||
const conflicts: Array<{ secretId: string; op: CommitType }> = [];
|
||||
let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create);
|
||||
const conflicts: Array<{ secretId: string; op: SecretOperations }> = [];
|
||||
let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Create);
|
||||
if (secretCreationCommits.length) {
|
||||
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({
|
||||
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({
|
||||
folderId,
|
||||
secretDAL,
|
||||
inputSecrets: secretCreationCommits.map(({ secretBlindIndex }) => {
|
||||
if (!secretBlindIndex) {
|
||||
throw new BadRequestError({
|
||||
@ -319,17 +317,19 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretCreationCommits
|
||||
.filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""])
|
||||
.forEach((el) => {
|
||||
conflicts.push({ op: CommitType.Create, secretId: el.id });
|
||||
conflicts.push({ op: SecretOperations.Create, secretId: el.id });
|
||||
});
|
||||
secretCreationCommits = secretCreationCommits.filter(
|
||||
({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""]
|
||||
);
|
||||
}
|
||||
|
||||
let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update);
|
||||
let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Update);
|
||||
if (secretUpdationCommits.length) {
|
||||
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({
|
||||
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({
|
||||
folderId,
|
||||
secretDAL,
|
||||
userId: "",
|
||||
inputSecrets: secretUpdationCommits
|
||||
.filter(({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex)
|
||||
.map(({ secretBlindIndex }) => {
|
||||
@ -347,7 +347,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
(secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId
|
||||
)
|
||||
.forEach((el) => {
|
||||
conflicts.push({ op: CommitType.Update, secretId: el.id });
|
||||
conflicts.push({ op: SecretOperations.Update, secretId: el.id });
|
||||
});
|
||||
|
||||
secretUpdationCommits = secretUpdationCommits.filter(
|
||||
@ -356,11 +356,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Delete);
|
||||
const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Delete);
|
||||
const botKey = await projectBotService.getBotKey(projectId).catch(() => null);
|
||||
const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => {
|
||||
const newSecrets = secretCreationCommits.length
|
||||
? await secretService.fnSecretBulkInsert({
|
||||
? await fnSecretBulkInsert({
|
||||
tx,
|
||||
folderId,
|
||||
inputSecrets: secretCreationCommits.map((el) => ({
|
||||
@ -403,7 +403,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
})
|
||||
: [];
|
||||
const updatedSecrets = secretUpdationCommits.length
|
||||
? await secretService.fnSecretBulkUpdate({
|
||||
? await fnSecretBulkUpdate({
|
||||
folderId,
|
||||
projectId,
|
||||
tx,
|
||||
@ -449,11 +449,13 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
})
|
||||
: [];
|
||||
const deletedSecret = secretDeletionCommits.length
|
||||
? await secretService.fnSecretBulkDelete({
|
||||
? await fnSecretBulkDelete({
|
||||
projectId,
|
||||
folderId,
|
||||
tx,
|
||||
actorId: "",
|
||||
secretDAL,
|
||||
secretQueueService,
|
||||
inputSecrets: secretDeletionCommits.map(({ secretBlindIndex }) => {
|
||||
if (!secretBlindIndex) {
|
||||
throw new BadRequestError({
|
||||
@ -480,12 +482,14 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
};
|
||||
});
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
const folder = await folderDAL.findById(folderId);
|
||||
// TODO(akhilmhdh-pg): change query to do secret path from folder
|
||||
const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||
await secretQueueService.syncSecrets({
|
||||
projectId,
|
||||
secretPath: "/",
|
||||
environment: folder?.environment.envSlug as string
|
||||
secretPath: folder.path,
|
||||
environmentSlug: folder.environmentSlug,
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
return mergeStatus;
|
||||
};
|
||||
@ -533,9 +537,9 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const commits: Omit<TSecretApprovalRequestsSecretsInsert, "requestId">[] = [];
|
||||
const commitTagIds: Record<string, string[]> = {};
|
||||
// for created secret approval change
|
||||
const createdSecrets = data[CommitType.Create];
|
||||
const createdSecrets = data[SecretOperations.Create];
|
||||
if (createdSecrets && createdSecrets?.length) {
|
||||
const { keyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({
|
||||
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
|
||||
inputSecrets: createdSecrets,
|
||||
folderId,
|
||||
isNew: true,
|
||||
@ -546,7 +550,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
commits.push(
|
||||
...createdSecrets.map(({ secretName, ...el }) => ({
|
||||
...el,
|
||||
op: CommitType.Create as const,
|
||||
op: SecretOperations.Create as const,
|
||||
version: 1,
|
||||
secretBlindIndex: keyName2BlindIndex[secretName],
|
||||
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
||||
@ -558,12 +562,12 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
});
|
||||
}
|
||||
// not secret approval for update operations
|
||||
const updatedSecrets = data[CommitType.Update];
|
||||
const updatedSecrets = data[SecretOperations.Update];
|
||||
if (updatedSecrets && updatedSecrets?.length) {
|
||||
// get all blind index
|
||||
// Find all those secrets
|
||||
// if not throw not found
|
||||
const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await secretService.fnSecretBlindIndexCheck({
|
||||
const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await fnSecretBlindIndexCheck({
|
||||
inputSecrets: updatedSecrets,
|
||||
folderId,
|
||||
isNew: false,
|
||||
@ -574,8 +578,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
// now find any secret that needs to update its name
|
||||
// same process as above
|
||||
const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) => Boolean(newSecretName));
|
||||
const { keyName2BlindIndex: newKeyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({
|
||||
inputSecrets: nameUpdatedSecrets,
|
||||
const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({
|
||||
inputSecrets: nameUpdatedSecrets.map(({ newSecretName }) => ({ secretName: newSecretName as string })),
|
||||
folderId,
|
||||
isNew: true,
|
||||
blindIndexCfg,
|
||||
@ -592,14 +596,14 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const secretId = secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0].id;
|
||||
const secretBlindIndex =
|
||||
newSecretName && newKeyName2BlindIndex[newSecretName]
|
||||
? newKeyName2BlindIndex?.[secretName]
|
||||
? newKeyName2BlindIndex?.[newSecretName]
|
||||
: keyName2BlindIndex[secretName];
|
||||
// add tags
|
||||
if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds;
|
||||
return {
|
||||
...latestSecretVersions[secretId],
|
||||
...el,
|
||||
op: CommitType.Update as const,
|
||||
op: SecretOperations.Update as const,
|
||||
secret: secretId,
|
||||
secretVersion: latestSecretVersions[secretId].id,
|
||||
secretBlindIndex,
|
||||
@ -609,12 +613,12 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
);
|
||||
}
|
||||
// deleted secrets
|
||||
const deletedSecrets = data[CommitType.Delete];
|
||||
const deletedSecrets = data[SecretOperations.Delete];
|
||||
if (deletedSecrets && deletedSecrets.length) {
|
||||
// get all blind index
|
||||
// Find all those secrets
|
||||
// if not throw not found
|
||||
const { keyName2BlindIndex, secrets } = await secretService.fnSecretBlindIndexCheck({
|
||||
const { keyName2BlindIndex, secrets } = await fnSecretBlindIndexCheck({
|
||||
inputSecrets: deletedSecrets,
|
||||
folderId,
|
||||
isNew: false,
|
||||
@ -635,7 +639,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
if (!latestSecretVersions[secretId].secretBlindIndex)
|
||||
throw new BadRequestError({ message: "Failed to find secret blind index" });
|
||||
return {
|
||||
op: CommitType.Delete as const,
|
||||
op: SecretOperations.Delete as const,
|
||||
...latestSecretVersions[secretId],
|
||||
secretBlindIndex: latestSecretVersions[secretId].secretBlindIndex as string,
|
||||
secret: secretId,
|
||||
|
@ -1,11 +1,6 @@
|
||||
import { TImmutableDBKeys, TSecretApprovalPolicies, TSecretApprovalRequestsSecrets } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum CommitType {
|
||||
Create = "create",
|
||||
Update = "update",
|
||||
Delete = "delete"
|
||||
}
|
||||
import { SecretOperations } from "@app/services/secret/secret-types";
|
||||
|
||||
export enum RequestState {
|
||||
Open = "open",
|
||||
@ -18,14 +13,14 @@ export enum ApprovalStatus {
|
||||
REJECTED = "rejected"
|
||||
}
|
||||
|
||||
type TApprovalCreateSecret = Omit<
|
||||
export type TApprovalCreateSecret = Omit<
|
||||
TSecretApprovalRequestsSecrets,
|
||||
TImmutableDBKeys | "version" | "algorithm" | "keyEncoding" | "requestId" | "op" | "secretVersion" | "secretBlindIndex"
|
||||
> & {
|
||||
secretName: string;
|
||||
tagIds?: string[];
|
||||
};
|
||||
type TApprovalUpdateSecret = Partial<TApprovalCreateSecret> & {
|
||||
export type TApprovalUpdateSecret = Partial<TApprovalCreateSecret> & {
|
||||
secretName: string;
|
||||
newSecretName?: string;
|
||||
tagIds?: string[];
|
||||
@ -36,9 +31,9 @@ export type TGenerateSecretApprovalRequestDTO = {
|
||||
secretPath: string;
|
||||
policy: TSecretApprovalPolicies;
|
||||
data: {
|
||||
[CommitType.Create]?: TApprovalCreateSecret[];
|
||||
[CommitType.Update]?: TApprovalUpdateSecret[];
|
||||
[CommitType.Delete]?: { secretName: string }[];
|
||||
[SecretOperations.Create]?: TApprovalCreateSecret[];
|
||||
[SecretOperations.Update]?: TApprovalUpdateSecret[];
|
||||
[SecretOperations.Delete]?: { secretName: string }[];
|
||||
};
|
||||
} & TProjectPermission;
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
export const MAX_REPLICATION_DEPTH = 5;
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSecretReplicationDALFactory = ReturnType<typeof secretReplicationDALFactory>;
|
||||
|
||||
export const secretReplicationDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.SecretVersion);
|
||||
return orm;
|
||||
};
|
@ -0,0 +1,485 @@
|
||||
import { SecretType, TSecrets } from "@app/db/schemas";
|
||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { groupBy, unique } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns";
|
||||
import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue";
|
||||
import { SecretOperations } from "@app/services/secret/secret-types";
|
||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { ReservedFolders } from "@app/services/secret-folder/secret-folder-types";
|
||||
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
|
||||
import { fnSecretsFromImports } from "@app/services/secret-import/secret-import-fns";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
|
||||
import { MAX_REPLICATION_DEPTH } from "./secret-replication-constants";
|
||||
|
||||
type TSecretReplicationServiceFactoryDep = {
|
||||
secretDAL: Pick<
|
||||
TSecretDALFactory,
|
||||
"find" | "findByBlindIndexes" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction"
|
||||
>;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "find" | "insertMany" | "update" | "findLatestVersionMany">;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "updateById" | "findByFolderIds">;
|
||||
folderDAL: Pick<
|
||||
TSecretFolderDALFactory,
|
||||
"findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath"
|
||||
>;
|
||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "find" | "insertMany">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "replicateSecrets">;
|
||||
queueService: Pick<TQueueServiceFactory, "start" | "listen" | "queue" | "stopJobById">;
|
||||
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
|
||||
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
|
||||
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret" | "find">;
|
||||
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findOne">;
|
||||
secretApprovalRequestSecretDAL: Pick<
|
||||
TSecretApprovalRequestSecretDALFactory,
|
||||
"insertMany" | "insertApprovalSecretTags"
|
||||
>;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
};
|
||||
|
||||
export type TSecretReplicationServiceFactory = ReturnType<typeof secretReplicationServiceFactory>;
|
||||
const SECRET_IMPORT_SUCCESS_LOCK = 10;
|
||||
|
||||
const keystoreReplicationSuccessKey = (jobId: string, secretImportId: string) => `${jobId}-${secretImportId}`;
|
||||
const getReplicationKeyLockPrefix = (projectId: string, environmentSlug: string, secretPath: string) =>
|
||||
`REPLICATION_SECRET_${projectId}-${environmentSlug}-${secretPath}`;
|
||||
export const getReplicationFolderName = (importId: string) => `${ReservedFolders.SecretReplication}${importId}`;
|
||||
|
||||
const getDecryptedKeyValue = (key: string, secret: TSecrets) => {
|
||||
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
return { key: secretKey, value: secretValue };
|
||||
};
|
||||
|
||||
export const secretReplicationServiceFactory = ({
|
||||
secretDAL,
|
||||
queueService,
|
||||
secretVersionDAL,
|
||||
secretImportDAL,
|
||||
keyStore,
|
||||
secretVersionTagDAL,
|
||||
secretTagDAL,
|
||||
folderDAL,
|
||||
secretApprovalPolicyService,
|
||||
secretApprovalRequestSecretDAL,
|
||||
secretApprovalRequestDAL,
|
||||
secretQueueService,
|
||||
projectMembershipDAL,
|
||||
projectBotService
|
||||
}: TSecretReplicationServiceFactoryDep) => {
|
||||
const getReplicatedSecrets = (
|
||||
botKey: string,
|
||||
localSecrets: TSecrets[],
|
||||
importedSecrets: { secrets: TSecrets[] }[]
|
||||
) => {
|
||||
const deDupe = new Set<string>();
|
||||
const secrets = localSecrets
|
||||
.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex))
|
||||
.map((el) => {
|
||||
const decryptedSecret = getDecryptedKeyValue(botKey, el);
|
||||
deDupe.add(decryptedSecret.key);
|
||||
return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value };
|
||||
});
|
||||
|
||||
for (let i = importedSecrets.length - 1; i >= 0; i = -1) {
|
||||
importedSecrets[i].secrets.forEach((el) => {
|
||||
const decryptedSecret = getDecryptedKeyValue(botKey, el);
|
||||
if (deDupe.has(decryptedSecret.key) || !el.secretBlindIndex) {
|
||||
return;
|
||||
}
|
||||
deDupe.add(decryptedSecret.key);
|
||||
secrets.push({ ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value });
|
||||
});
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
// IMPORTANT NOTE BEFORE READING THE FUNCTION
|
||||
// SOURCE - Where secrets are copied from
|
||||
// DESTINATION - Where the replicated imports that points to SOURCE from Destination
|
||||
queueService.start(QueueName.SecretReplication, async (job) => {
|
||||
logger.info(job.data, "Replication started");
|
||||
const {
|
||||
secretPath,
|
||||
environmentSlug,
|
||||
projectId,
|
||||
actorId,
|
||||
actor,
|
||||
pickOnlyImportIds,
|
||||
_deDupeReplicationQueue: deDupeReplicationQueue,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_depth: depth = 0
|
||||
} = job.data;
|
||||
if (depth > MAX_REPLICATION_DEPTH) return;
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath);
|
||||
if (!folder) return;
|
||||
|
||||
// the the replicated imports made to the source. These are the destinations
|
||||
const destinationSecretImports = await secretImportDAL.find({
|
||||
importPath: secretPath,
|
||||
importEnv: folder.envId
|
||||
});
|
||||
|
||||
// CASE: normal mode <- link import <- replicated import
|
||||
const nonReplicatedDestinationImports = destinationSecretImports.filter(({ isReplication }) => !isReplication);
|
||||
if (nonReplicatedDestinationImports.length) {
|
||||
// keep calling sync secret for all the imports made
|
||||
const importedFolderIds = unique(nonReplicatedDestinationImports, (i) => i.folderId).map(
|
||||
({ folderId }) => folderId
|
||||
);
|
||||
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
|
||||
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
|
||||
await Promise.all(
|
||||
nonReplicatedDestinationImports
|
||||
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
|
||||
// filter out already synced ones
|
||||
.filter(
|
||||
({ folderId }) =>
|
||||
!deDupeQueue?.[
|
||||
uniqueSecretQueueKey(
|
||||
foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
foldersGroupedById[folderId][0]?.path as string
|
||||
)
|
||||
]
|
||||
)
|
||||
.map(({ folderId }) =>
|
||||
secretQueueService.replicateSecrets({
|
||||
projectId,
|
||||
secretPath: foldersGroupedById[folderId][0]?.path as string,
|
||||
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
actorId,
|
||||
actor,
|
||||
_depth: depth + 1,
|
||||
_deDupeReplicationQueue: deDupeReplicationQueue,
|
||||
_deDupeQueue: deDupeQueue
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let destinationReplicatedSecretImports = destinationSecretImports.filter(({ isReplication }) =>
|
||||
Boolean(isReplication)
|
||||
);
|
||||
destinationReplicatedSecretImports = pickOnlyImportIds
|
||||
? destinationReplicatedSecretImports.filter(({ id }) => pickOnlyImportIds?.includes(id))
|
||||
: destinationReplicatedSecretImports;
|
||||
if (!destinationReplicatedSecretImports.length) return;
|
||||
|
||||
const botKey = await projectBotService.getBotKey(projectId);
|
||||
|
||||
// these are the secrets to be added in replicated folders
|
||||
const sourceLocalSecrets = await secretDAL.find({ folderId: folder.id, type: SecretType.Shared });
|
||||
const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id });
|
||||
const sourceImportedSecrets = await fnSecretsFromImports({
|
||||
allowedImports: sourceSecretImports,
|
||||
secretDAL,
|
||||
folderDAL,
|
||||
secretImportDAL
|
||||
});
|
||||
// secrets that gets replicated across imports
|
||||
const sourceSecrets = getReplicatedSecrets(botKey, sourceLocalSecrets, sourceImportedSecrets);
|
||||
const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string);
|
||||
|
||||
const lock = await keyStore.acquireLock(
|
||||
[getReplicationKeyLockPrefix(projectId, environmentSlug, secretPath)],
|
||||
5000
|
||||
);
|
||||
|
||||
try {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
for (const destinationSecretImport of destinationReplicatedSecretImports) {
|
||||
try {
|
||||
const hasJobCompleted = await keyStore.getItem(
|
||||
keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id),
|
||||
KeyStorePrefixes.SecretReplication
|
||||
);
|
||||
if (hasJobCompleted) {
|
||||
logger.info(
|
||||
{ jobId: job.id, importId: destinationSecretImport.id },
|
||||
"Skipping this job as this has been successfully replicated."
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
continue;
|
||||
}
|
||||
|
||||
const [destinationFolder] = await folderDAL.findSecretPathByFolderIds(projectId, [
|
||||
destinationSecretImport.folderId
|
||||
]);
|
||||
if (!destinationFolder) throw new BadRequestError({ message: "Imported folder not found" });
|
||||
|
||||
let destinationReplicationFolder = await folderDAL.findOne({
|
||||
parentId: destinationFolder.id,
|
||||
name: getReplicationFolderName(destinationSecretImport.id),
|
||||
isReserved: true
|
||||
});
|
||||
if (!destinationReplicationFolder) {
|
||||
destinationReplicationFolder = await folderDAL.create({
|
||||
parentId: destinationFolder.id,
|
||||
name: getReplicationFolderName(destinationSecretImport.id),
|
||||
envId: destinationFolder.envId,
|
||||
isReserved: true
|
||||
});
|
||||
}
|
||||
const destinationReplicationFolderId = destinationReplicationFolder.id;
|
||||
|
||||
const destinationLocalSecretsFromDB = await secretDAL.find({
|
||||
folderId: destinationReplicationFolderId
|
||||
});
|
||||
const destinationLocalSecrets = destinationLocalSecretsFromDB.map((el) => {
|
||||
const decryptedSecret = getDecryptedKeyValue(botKey, el);
|
||||
return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value };
|
||||
});
|
||||
|
||||
const destinationLocalSecretsGroupedByBlindIndex = groupBy(
|
||||
destinationLocalSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)),
|
||||
(i) => i.secretBlindIndex as string
|
||||
);
|
||||
|
||||
const locallyCreatedSecrets = sourceSecrets
|
||||
.filter(
|
||||
({ secretBlindIndex }) => !destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]
|
||||
)
|
||||
.map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create
|
||||
|
||||
const locallyUpdatedSecrets = sourceSecrets
|
||||
.filter(
|
||||
({ secretBlindIndex, secretKey, secretValue }) =>
|
||||
destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] &&
|
||||
// if key or value changed
|
||||
(destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey ||
|
||||
destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !==
|
||||
secretValue)
|
||||
)
|
||||
.map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create
|
||||
|
||||
const locallyDeletedSecrets = destinationLocalSecrets
|
||||
.filter(({ secretBlindIndex }) => !sourceSecretsGroupByBlindIndex[secretBlindIndex as string]?.[0])
|
||||
.map((el) => ({ ...el, operation: SecretOperations.Delete }));
|
||||
|
||||
const isEmtpy =
|
||||
locallyCreatedSecrets.length + locallyUpdatedSecrets.length + locallyDeletedSecrets.length === 0;
|
||||
// eslint-disable-next-line
|
||||
if (isEmtpy) continue;
|
||||
|
||||
const policy = await secretApprovalPolicyService.getSecretApprovalPolicy(
|
||||
projectId,
|
||||
destinationFolder.environmentSlug,
|
||||
destinationFolder.path
|
||||
);
|
||||
// this means it should be a approval request rather than direct replication
|
||||
if (policy && actor === ActorType.USER) {
|
||||
const membership = await projectMembershipDAL.findOne({ projectId, userId: actorId });
|
||||
if (!membership) {
|
||||
logger.error("Project membership not found in %s for user %s", projectId, actorId);
|
||||
return;
|
||||
}
|
||||
|
||||
const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id);
|
||||
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(
|
||||
destinationReplicationFolderId,
|
||||
localSecretsLatestVersions
|
||||
);
|
||||
await secretApprovalRequestDAL.transaction(async (tx) => {
|
||||
const approvalRequestDoc = await secretApprovalRequestDAL.create(
|
||||
{
|
||||
folderId: destinationReplicationFolderId,
|
||||
slug: alphaNumericNanoId(),
|
||||
policyId: policy.id,
|
||||
status: "open",
|
||||
hasMerged: false,
|
||||
committerId: membership.id,
|
||||
isReplicated: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
const commits = locallyCreatedSecrets
|
||||
.concat(locallyUpdatedSecrets)
|
||||
.concat(locallyDeletedSecrets)
|
||||
.map((doc) => {
|
||||
const { operation } = doc;
|
||||
const localSecret = destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0];
|
||||
|
||||
return {
|
||||
op: operation,
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
requestId: approvalRequestDoc.id,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||
// except create operation other two needs the secret id and version id
|
||||
...(operation !== SecretOperations.Create
|
||||
? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id }
|
||||
: {})
|
||||
};
|
||||
});
|
||||
const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx);
|
||||
|
||||
return { ...approvalRequestDoc, commits: approvalCommits };
|
||||
});
|
||||
} else {
|
||||
await secretDAL.transaction(async (tx) => {
|
||||
if (locallyCreatedSecrets.length) {
|
||||
await fnSecretBulkInsert({
|
||||
folderId: destinationReplicationFolderId,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
tx,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
inputSecrets: locallyCreatedSecrets.map((doc) => {
|
||||
return {
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
if (locallyUpdatedSecrets.length) {
|
||||
await fnSecretBulkUpdate({
|
||||
projectId,
|
||||
folderId: destinationReplicationFolderId,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
tx,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
inputSecrets: locallyUpdatedSecrets.map((doc) => {
|
||||
return {
|
||||
filter: {
|
||||
folderId: destinationReplicationFolderId,
|
||||
id: destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id
|
||||
},
|
||||
data: {
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
if (locallyDeletedSecrets.length) {
|
||||
await secretDAL.delete(
|
||||
{
|
||||
$in: {
|
||||
id: locallyDeletedSecrets.map(({ id }) => id)
|
||||
},
|
||||
folderId: destinationReplicationFolderId
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await secretQueueService.syncSecrets({
|
||||
projectId,
|
||||
secretPath: destinationFolder.path,
|
||||
environmentSlug: destinationFolder.environmentSlug,
|
||||
actorId,
|
||||
actor,
|
||||
_depth: depth + 1,
|
||||
_deDupeReplicationQueue: deDupeReplicationQueue,
|
||||
_deDupeQueue: deDupeQueue
|
||||
});
|
||||
}
|
||||
|
||||
// this is used to avoid multiple times generating secret approval by failed one
|
||||
await keyStore.setItemWithExpiry(
|
||||
keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id),
|
||||
SECRET_IMPORT_SUCCESS_LOCK,
|
||||
1,
|
||||
KeyStorePrefixes.SecretReplication
|
||||
);
|
||||
|
||||
await secretImportDAL.updateById(destinationSecretImport.id, {
|
||||
lastReplicated: new Date(),
|
||||
replicationStatus: null,
|
||||
isReplicationSuccess: true
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`Failed to replicate secret with import id=[${destinationSecretImport.id}] env=[${destinationSecretImport.importEnv.slug}] path=[${destinationSecretImport.importPath}]`
|
||||
);
|
||||
await secretImportDAL.updateById(destinationSecretImport.id, {
|
||||
lastReplicated: new Date(),
|
||||
replicationStatus: (err as Error)?.message.slice(0, 500),
|
||||
isReplicationSuccess: false
|
||||
});
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
} finally {
|
||||
await lock.release();
|
||||
logger.info(job.data, "Replication finished");
|
||||
}
|
||||
});
|
||||
|
||||
queueService.listen(QueueName.SecretReplication, "failed", (job, err) => {
|
||||
logger.error(err, "Failed to replicate secret", job?.data);
|
||||
});
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export type TSyncSecretReplicationDTO = {
|
||||
id: string;
|
||||
};
|
@ -220,7 +220,7 @@ export const secretSnapshotServiceFactory = ({
|
||||
const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id);
|
||||
// this will remove all secrets and folders on child
|
||||
// due to sql foreign key and link list connection removing the folders removes everything below too
|
||||
const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId }, tx);
|
||||
const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx);
|
||||
const deletedTopLevelFolders = groupBy(
|
||||
deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId),
|
||||
(item) => item.id
|
||||
|
@ -1,20 +1,75 @@
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
import { Redlock, Settings } from "@app/lib/red-lock";
|
||||
|
||||
export type TKeyStoreFactory = ReturnType<typeof keyStoreFactory>;
|
||||
|
||||
// all the key prefixes used must be set here to avoid conflict
|
||||
export enum KeyStorePrefixes {
|
||||
SecretReplication = "secret-replication-import-lock"
|
||||
}
|
||||
|
||||
type TWaitTillReady = {
|
||||
key: string;
|
||||
waitingCb?: () => void;
|
||||
keyCheckCb: (val: string | null) => boolean;
|
||||
waitIteration?: number;
|
||||
delay?: number;
|
||||
jitter?: number;
|
||||
};
|
||||
|
||||
export const keyStoreFactory = (redisUrl: string) => {
|
||||
const redis = new Redis(redisUrl);
|
||||
const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 });
|
||||
|
||||
const setItem = async (key: string, value: string | number | Buffer) => redis.set(key, value);
|
||||
const setItem = async (key: string, value: string | number | Buffer, prefix?: string) =>
|
||||
redis.set(prefix ? `${prefix}:${key}` : key, value);
|
||||
|
||||
const getItem = async (key: string) => redis.get(key);
|
||||
const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key);
|
||||
|
||||
const setItemWithExpiry = async (key: string, exp: number | string, value: string | number | Buffer) =>
|
||||
redis.setex(key, exp, value);
|
||||
const setItemWithExpiry = async (
|
||||
key: string,
|
||||
exp: number | string,
|
||||
value: string | number | Buffer,
|
||||
prefix?: string
|
||||
) => redis.setex(prefix ? `${prefix}:${key}` : key, exp, value);
|
||||
|
||||
const deleteItem = async (key: string) => redis.del(key);
|
||||
|
||||
const incrementBy = async (key: string, value: number) => redis.incrby(key, value);
|
||||
|
||||
return { setItem, getItem, setItemWithExpiry, deleteItem, incrementBy };
|
||||
const waitTillReady = async ({
|
||||
key,
|
||||
waitingCb,
|
||||
keyCheckCb,
|
||||
waitIteration = 10,
|
||||
delay = 1000,
|
||||
jitter = 200
|
||||
}: TWaitTillReady) => {
|
||||
let attempts = 0;
|
||||
let isReady = keyCheckCb(await getItem(key));
|
||||
while (!isReady) {
|
||||
if (attempts > waitIteration) return;
|
||||
// eslint-disable-next-line
|
||||
await new Promise((resolve) => {
|
||||
waitingCb?.();
|
||||
setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter)));
|
||||
});
|
||||
attempts += 1;
|
||||
// eslint-disable-next-line
|
||||
isReady = keyCheckCb(await getItem(key, "wait_till_ready"));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
setItem,
|
||||
getItem,
|
||||
setItemWithExpiry,
|
||||
deleteItem,
|
||||
incrementBy,
|
||||
acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) {
|
||||
return redisLock.acquire(resources, duration, settings);
|
||||
},
|
||||
waitTillReady
|
||||
};
|
||||
};
|
||||
|
@ -225,7 +225,8 @@ export const PROJECT_IDENTITIES = {
|
||||
roles: {
|
||||
description: "A list of role slugs to assign to the identity project membership.",
|
||||
role: "The role slug to assign to the newly created identity project membership.",
|
||||
isTemporary: "Whether the assigned role is temporary.",
|
||||
isTemporary:
|
||||
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
|
||||
temporaryMode: "Type of temporary expiry.",
|
||||
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h",
|
||||
temporaryAccessStartTime: "Time to which the temporary access starts"
|
||||
@ -242,7 +243,8 @@ export const PROJECT_IDENTITIES = {
|
||||
roles: {
|
||||
description: "A list of role slugs to assign to the newly created identity project membership.",
|
||||
role: "The role slug to assign to the newly created identity project membership.",
|
||||
isTemporary: "Whether the assigned role is temporary.",
|
||||
isTemporary:
|
||||
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
|
||||
temporaryMode: "Type of temporary expiry.",
|
||||
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h",
|
||||
temporaryAccessStartTime: "Time to which the temporary access starts"
|
||||
@ -519,7 +521,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||
projectSlug: "The slug of the project of the identity in.",
|
||||
identityId: "The ID of the identity to create.",
|
||||
slug: "The slug of the privilege to create.",
|
||||
permissions: `The permission object for the privilege.
|
||||
permissions: `@deprecated - use privilegePermission
|
||||
The permission object for the privilege.
|
||||
- Read secrets
|
||||
\`\`\`
|
||||
{ "permissions": [{"action": "read", "subject": "secrets"]}
|
||||
@ -533,6 +536,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||
- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] }
|
||||
\`\`\`
|
||||
`,
|
||||
privilegePermission: "The permission object for the privilege.",
|
||||
isPackPermission: "Whether the server should pack(compact) the permission object.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
@ -544,7 +548,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||
identityId: "The ID of the identity to update.",
|
||||
slug: "The slug of the privilege to update.",
|
||||
newSlug: "The new slug of the privilege to update.",
|
||||
permissions: `The permission object for the privilege.
|
||||
permissions: `@deprecated - use privilegePermission
|
||||
The permission object for the privilege.
|
||||
- Read secrets
|
||||
\`\`\`
|
||||
{ "permissions": [{"action": "read", "subject": "secrets"]}
|
||||
@ -558,6 +563,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||
- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] }
|
||||
\`\`\`
|
||||
`,
|
||||
privilegePermission: "The permission object for the privilege.",
|
||||
isTemporary: "Whether the privilege is temporary.",
|
||||
temporaryMode: "Type of temporary access given. Types: relative",
|
||||
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
|
||||
@ -655,6 +661,7 @@ export const INTEGRATION = {
|
||||
targetServiceId:
|
||||
"The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank",
|
||||
owner: "External integration providers service entity owner. Used in Github.",
|
||||
url: "The self-hosted URL of the platform to integrate with",
|
||||
path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault",
|
||||
region: "AWS region to sync secrets to.",
|
||||
scope: "Scope of the provider. Used by Github, Qovery",
|
||||
@ -715,3 +722,32 @@ export const AUDIT_LOG_STREAMS = {
|
||||
id: "The ID of the audit log stream to get details."
|
||||
}
|
||||
};
|
||||
|
||||
export const PROJECT_ROLE = {
|
||||
CREATE: {
|
||||
projectSlug: "Slug of the project to create the role for.",
|
||||
slug: "The slug of the role.",
|
||||
name: "The name of the role.",
|
||||
description: "The description for the role.",
|
||||
permissions: "The permissions assigned to the role."
|
||||
},
|
||||
UPDATE: {
|
||||
projectSlug: "Slug of the project to update the role for.",
|
||||
roleId: "The ID of the role to update",
|
||||
slug: "The slug of the role.",
|
||||
name: "The name of the role.",
|
||||
description: "The description for the role.",
|
||||
permissions: "The permissions assigned to the role."
|
||||
},
|
||||
DELETE: {
|
||||
projectSlug: "Slug of the project to delete this role for.",
|
||||
roleId: "The ID of the role to update"
|
||||
},
|
||||
GET_ROLE_BY_SLUG: {
|
||||
projectSlug: "The slug of the project.",
|
||||
roleSlug: "The slug of the role to get details"
|
||||
},
|
||||
LIST: {
|
||||
projectSlug: "The slug of the project to list the roles of."
|
||||
}
|
||||
};
|
||||
|
49
backend/src/lib/crypto/cipher/cipher.ts
Normal file
49
backend/src/lib/crypto/cipher/cipher.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types";
|
||||
|
||||
const getIvLength = () => {
|
||||
return 12;
|
||||
};
|
||||
|
||||
const getTagLength = () => {
|
||||
return 16;
|
||||
};
|
||||
|
||||
export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => {
|
||||
const IV_LENGTH = getIvLength();
|
||||
const TAG_LENGTH = getTagLength();
|
||||
|
||||
const encrypt = (text: Buffer, key: Buffer) => {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(type, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
// Get the authentication tag
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Concatenate IV, encrypted text, and tag into a single buffer
|
||||
const ciphertextBlob = Buffer.concat([iv, encrypted, tag]);
|
||||
return ciphertextBlob;
|
||||
};
|
||||
|
||||
const decrypt = (ciphertextBlob: Buffer, key: Buffer) => {
|
||||
// Extract the IV, encrypted text, and tag from the buffer
|
||||
const iv = ciphertextBlob.subarray(0, IV_LENGTH);
|
||||
const tag = ciphertextBlob.subarray(-TAG_LENGTH);
|
||||
const encrypted = ciphertextBlob.subarray(IV_LENGTH, -TAG_LENGTH);
|
||||
|
||||
const decipher = crypto.createDecipheriv(type, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
return {
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
};
|
2
backend/src/lib/crypto/cipher/index.ts
Normal file
2
backend/src/lib/crypto/cipher/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { symmetricCipherService } from "./cipher";
|
||||
export { SymmetricEncryption } from "./types";
|
9
backend/src/lib/crypto/cipher/types.ts
Normal file
9
backend/src/lib/crypto/cipher/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export enum SymmetricEncryption {
|
||||
AES_GCM_256 = "aes-256-gcm",
|
||||
AES_GCM_128 = "aes-128-gcm"
|
||||
}
|
||||
|
||||
export type TSymmetricEncryptionFns = {
|
||||
encrypt: (text: Buffer, key: Buffer) => Buffer;
|
||||
decrypt: (blob: Buffer, key: Buffer) => Buffer;
|
||||
};
|
@ -11,6 +11,8 @@ import { getConfig } from "../config/env";
|
||||
export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s);
|
||||
export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u);
|
||||
|
||||
export const randomSecureBytes = (length = 32) => crypto.randomBytes(length);
|
||||
|
||||
export type TDecryptSymmetricInput = {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
|
@ -9,7 +9,8 @@ export {
|
||||
encryptAsymmetric,
|
||||
encryptSymmetric,
|
||||
encryptSymmetric128BitHexKeyUTF8,
|
||||
generateAsymmetricKeyPair
|
||||
generateAsymmetricKeyPair,
|
||||
randomSecureBytes
|
||||
} from "./encryption";
|
||||
export {
|
||||
decryptIntegrationAuths,
|
||||
|
@ -104,24 +104,68 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
|
||||
throw new DatabaseError({ error, name: "Create" });
|
||||
}
|
||||
},
|
||||
updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => {
|
||||
updateById: async (
|
||||
id: string,
|
||||
{
|
||||
$incr,
|
||||
$decr,
|
||||
...data
|
||||
}: Tables[Tname]["update"] & {
|
||||
$incr?: { [x in keyof Partial<Tables[Tname]["base"]>]: number };
|
||||
$decr?: { [x in keyof Partial<Tables[Tname]["base"]>]: number };
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const [res] = await (tx || db)(tableName)
|
||||
const query = (tx || db)(tableName)
|
||||
.where({ id } as never)
|
||||
.update(data as never)
|
||||
.returning("*");
|
||||
return res;
|
||||
if ($incr) {
|
||||
Object.entries($incr).forEach(([incrementField, incrementValue]) => {
|
||||
void query.increment(incrementField, incrementValue);
|
||||
});
|
||||
}
|
||||
if ($decr) {
|
||||
Object.entries($decr).forEach(([incrementField, incrementValue]) => {
|
||||
void query.decrement(incrementField, incrementValue);
|
||||
});
|
||||
}
|
||||
const [docs] = await query;
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update by id" });
|
||||
}
|
||||
},
|
||||
update: async (filter: TFindFilter<Tables[Tname]["base"]>, data: Tables[Tname]["update"], tx?: Knex) => {
|
||||
update: async (
|
||||
filter: TFindFilter<Tables[Tname]["base"]>,
|
||||
{
|
||||
$incr,
|
||||
$decr,
|
||||
...data
|
||||
}: Tables[Tname]["update"] & {
|
||||
$incr?: { [x in keyof Partial<Tables[Tname]["base"]>]: number };
|
||||
$decr?: { [x in keyof Partial<Tables[Tname]["base"]>]: number };
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const res = await (tx || db)(tableName)
|
||||
const query = (tx || db)(tableName)
|
||||
.where(buildFindFilter(filter))
|
||||
.update(data as never)
|
||||
.returning("*");
|
||||
return res;
|
||||
// increment and decrement operation in update
|
||||
if ($incr) {
|
||||
Object.entries($incr).forEach(([incrementField, incrementValue]) => {
|
||||
void query.increment(incrementField, incrementValue);
|
||||
});
|
||||
}
|
||||
if ($decr) {
|
||||
Object.entries($decr).forEach(([incrementField, incrementValue]) => {
|
||||
void query.increment(incrementField, incrementValue);
|
||||
});
|
||||
}
|
||||
return await query;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update" });
|
||||
}
|
||||
|
682
backend/src/lib/red-lock/index.ts
Normal file
682
backend/src/lib/red-lock/index.ts
Normal file
@ -0,0 +1,682 @@
|
||||
/* eslint-disable */
|
||||
// Source code credits: https://github.com/mike-marcacci/node-redlock
|
||||
// Taken to avoid external dependency
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
// AbortController became available as a global in node version 16. Once version
|
||||
// 14 reaches its end-of-life, this can be removed.
|
||||
|
||||
import { Redis as IORedisClient, Cluster as IORedisCluster } from "ioredis";
|
||||
|
||||
type Client = IORedisClient | IORedisCluster;
|
||||
|
||||
// Define script constants.
|
||||
const ACQUIRE_SCRIPT = `
|
||||
-- Return 0 if an entry already exists.
|
||||
for i, key in ipairs(KEYS) do
|
||||
if redis.call("exists", key) == 1 then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
-- Create an entry for each provided key.
|
||||
for i, key in ipairs(KEYS) do
|
||||
redis.call("set", key, ARGV[1], "PX", ARGV[2])
|
||||
end
|
||||
|
||||
-- Return the number of entries added.
|
||||
return #KEYS
|
||||
`;
|
||||
|
||||
const EXTEND_SCRIPT = `
|
||||
-- Return 0 if an entry exists with a *different* lock value.
|
||||
for i, key in ipairs(KEYS) do
|
||||
if redis.call("get", key) ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
-- Update the entry for each provided key.
|
||||
for i, key in ipairs(KEYS) do
|
||||
redis.call("set", key, ARGV[1], "PX", ARGV[2])
|
||||
end
|
||||
|
||||
-- Return the number of entries updated.
|
||||
return #KEYS
|
||||
`;
|
||||
|
||||
const RELEASE_SCRIPT = `
|
||||
local count = 0
|
||||
for i, key in ipairs(KEYS) do
|
||||
-- Only remove entries for *this* lock value.
|
||||
if redis.call("get", key) == ARGV[1] then
|
||||
redis.pcall("del", key)
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Return the number of entries removed.
|
||||
return count
|
||||
`;
|
||||
|
||||
export type ClientExecutionResult =
|
||||
| {
|
||||
client: Client;
|
||||
vote: "for";
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
client: Client;
|
||||
vote: "against";
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/*
|
||||
* This object contains a summary of results.
|
||||
*/
|
||||
export type ExecutionStats = {
|
||||
readonly membershipSize: number;
|
||||
readonly quorumSize: number;
|
||||
readonly votesFor: Set<Client>;
|
||||
readonly votesAgainst: Map<Client, Error>;
|
||||
};
|
||||
|
||||
/*
|
||||
* This object contains a summary of results. Because the result of an attempt
|
||||
* can sometimes be determined before all requests are finished, each attempt
|
||||
* contains a Promise that will resolve ExecutionStats once all requests are
|
||||
* finished. A rejection of these promises should be considered undefined
|
||||
* behavior and should cause a crash.
|
||||
*/
|
||||
export type ExecutionResult = {
|
||||
attempts: ReadonlyArray<Promise<ExecutionStats>>;
|
||||
start: number;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface Settings {
|
||||
readonly driftFactor: number;
|
||||
readonly retryCount: number;
|
||||
readonly retryDelay: number;
|
||||
readonly retryJitter: number;
|
||||
readonly automaticExtensionThreshold: number;
|
||||
}
|
||||
|
||||
// Define default settings.
|
||||
const defaultSettings: Readonly<Settings> = {
|
||||
driftFactor: 0.01,
|
||||
retryCount: 10,
|
||||
retryDelay: 200,
|
||||
retryJitter: 100,
|
||||
automaticExtensionThreshold: 500
|
||||
};
|
||||
|
||||
// Modifyng this object is forbidden.
|
||||
Object.freeze(defaultSettings);
|
||||
|
||||
/*
|
||||
* This error indicates a failure due to the existence of another lock for one
|
||||
* or more of the requested resources.
|
||||
*/
|
||||
export class ResourceLockedError extends Error {
|
||||
constructor(public readonly message: string) {
|
||||
super();
|
||||
this.name = "ResourceLockedError";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This error indicates a failure of an operation to pass with a quorum.
|
||||
*/
|
||||
export class ExecutionError extends Error {
|
||||
constructor(
|
||||
public readonly message: string,
|
||||
public readonly attempts: ReadonlyArray<Promise<ExecutionStats>>
|
||||
) {
|
||||
super();
|
||||
this.name = "ExecutionError";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* An object of this type is returned when a resource is successfully locked. It
|
||||
* contains convenience methods `release` and `extend` which perform the
|
||||
* associated Redlock method on itself.
|
||||
*/
|
||||
export class Lock {
|
||||
constructor(
|
||||
public readonly redlock: Redlock,
|
||||
public readonly resources: string[],
|
||||
public readonly value: string,
|
||||
public readonly attempts: ReadonlyArray<Promise<ExecutionStats>>,
|
||||
public expiration: number
|
||||
) {}
|
||||
|
||||
async release(): Promise<ExecutionResult> {
|
||||
return this.redlock.release(this);
|
||||
}
|
||||
|
||||
async extend(duration: number): Promise<Lock> {
|
||||
return this.redlock.extend(this, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export type RedlockAbortSignal = AbortSignal & { error?: Error };
|
||||
|
||||
/**
|
||||
* A redlock object is instantiated with an array of at least one redis client
|
||||
* and an optional `options` object. Properties of the Redlock object should NOT
|
||||
* be changed after it is first used, as doing so could have unintended
|
||||
* consequences for live locks.
|
||||
*/
|
||||
export class Redlock extends EventEmitter {
|
||||
public readonly clients: Set<Client>;
|
||||
public readonly settings: Settings;
|
||||
public readonly scripts: {
|
||||
readonly acquireScript: { value: string; hash: string };
|
||||
readonly extendScript: { value: string; hash: string };
|
||||
readonly releaseScript: { value: string; hash: string };
|
||||
};
|
||||
|
||||
public constructor(
|
||||
clients: Iterable<Client>,
|
||||
settings: Partial<Settings> = {},
|
||||
scripts: {
|
||||
readonly acquireScript?: string | ((script: string) => string);
|
||||
readonly extendScript?: string | ((script: string) => string);
|
||||
readonly releaseScript?: string | ((script: string) => string);
|
||||
} = {}
|
||||
) {
|
||||
super();
|
||||
|
||||
// Prevent crashes on error events.
|
||||
this.on("error", () => {
|
||||
// Because redlock is designed for high availability, it does not care if
|
||||
// a minority of redis instances/clusters fail at an operation.
|
||||
//
|
||||
// However, it can be helpful to monitor and log such cases. Redlock emits
|
||||
// an "error" event whenever it encounters an error, even if the error is
|
||||
// ignored in its normal operation.
|
||||
//
|
||||
// This function serves to prevent node's default behavior of crashing
|
||||
// when an "error" event is emitted in the absence of listeners.
|
||||
});
|
||||
|
||||
// Create a new array of client, to ensure no accidental mutation.
|
||||
this.clients = new Set(clients);
|
||||
if (this.clients.size === 0) {
|
||||
throw new Error("Redlock must be instantiated with at least one redis client.");
|
||||
}
|
||||
|
||||
// Customize the settings for this instance.
|
||||
this.settings = {
|
||||
driftFactor: typeof settings.driftFactor === "number" ? settings.driftFactor : defaultSettings.driftFactor,
|
||||
retryCount: typeof settings.retryCount === "number" ? settings.retryCount : defaultSettings.retryCount,
|
||||
retryDelay: typeof settings.retryDelay === "number" ? settings.retryDelay : defaultSettings.retryDelay,
|
||||
retryJitter: typeof settings.retryJitter === "number" ? settings.retryJitter : defaultSettings.retryJitter,
|
||||
automaticExtensionThreshold:
|
||||
typeof settings.automaticExtensionThreshold === "number"
|
||||
? settings.automaticExtensionThreshold
|
||||
: defaultSettings.automaticExtensionThreshold
|
||||
};
|
||||
|
||||
// Use custom scripts and script modifiers.
|
||||
const acquireScript =
|
||||
typeof scripts.acquireScript === "function" ? scripts.acquireScript(ACQUIRE_SCRIPT) : ACQUIRE_SCRIPT;
|
||||
const extendScript =
|
||||
typeof scripts.extendScript === "function" ? scripts.extendScript(EXTEND_SCRIPT) : EXTEND_SCRIPT;
|
||||
const releaseScript =
|
||||
typeof scripts.releaseScript === "function" ? scripts.releaseScript(RELEASE_SCRIPT) : RELEASE_SCRIPT;
|
||||
|
||||
this.scripts = {
|
||||
acquireScript: {
|
||||
value: acquireScript,
|
||||
hash: this._hash(acquireScript)
|
||||
},
|
||||
extendScript: {
|
||||
value: extendScript,
|
||||
hash: this._hash(extendScript)
|
||||
},
|
||||
releaseScript: {
|
||||
value: releaseScript,
|
||||
hash: this._hash(releaseScript)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sha1 hash compatible with redis evalsha.
|
||||
*/
|
||||
private _hash(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random string.
|
||||
*/
|
||||
private _random(): string {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* This method runs `.quit()` on all client connections.
|
||||
*/
|
||||
public async quit(): Promise<void> {
|
||||
const results = [];
|
||||
for (const client of this.clients) {
|
||||
results.push(client.quit());
|
||||
}
|
||||
|
||||
await Promise.all(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method acquires a locks on the resources for the duration specified by
|
||||
* the `duration`.
|
||||
*/
|
||||
public async acquire(resources: string[], duration: number, settings?: Partial<Settings>): Promise<Lock> {
|
||||
if (Math.floor(duration) !== duration) {
|
||||
throw new Error("Duration must be an integer value in milliseconds.");
|
||||
}
|
||||
|
||||
const value = this._random();
|
||||
|
||||
try {
|
||||
const { attempts, start } = await this._execute(
|
||||
this.scripts.acquireScript,
|
||||
resources,
|
||||
[value, duration],
|
||||
settings
|
||||
);
|
||||
|
||||
// Add 2 milliseconds to the drift to account for Redis expires precision,
|
||||
// which is 1 ms, plus the configured allowable drift factor.
|
||||
const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2;
|
||||
|
||||
return new Lock(this, resources, value, attempts, start + duration - drift);
|
||||
} catch (error) {
|
||||
// If there was an error acquiring the lock, release any partial lock
|
||||
// state that may exist on a minority of clients.
|
||||
await this._execute(this.scripts.releaseScript, resources, [value], {
|
||||
retryCount: 0
|
||||
}).catch(() => {
|
||||
// Any error here will be ignored.
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method unlocks the provided lock from all servers still persisting it.
|
||||
* It will fail with an error if it is unable to release the lock on a quorum
|
||||
* of nodes, but will make no attempt to restore the lock in the case of a
|
||||
* failure to release. It is safe to re-attempt a release or to ignore the
|
||||
* error, as the lock will automatically expire after its timeout.
|
||||
*/
|
||||
public async release(lock: Lock, settings?: Partial<Settings>): Promise<ExecutionResult> {
|
||||
// Immediately invalidate the lock.
|
||||
lock.expiration = 0;
|
||||
|
||||
// Attempt to release the lock.
|
||||
return this._execute(this.scripts.releaseScript, lock.resources, [lock.value], settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method extends a valid lock by the provided `duration`.
|
||||
*/
|
||||
public async extend(existing: Lock, duration: number, settings?: Partial<Settings>): Promise<Lock> {
|
||||
if (Math.floor(duration) !== duration) {
|
||||
throw new Error("Duration must be an integer value in milliseconds.");
|
||||
}
|
||||
|
||||
// The lock has already expired.
|
||||
if (existing.expiration < Date.now()) {
|
||||
throw new ExecutionError("Cannot extend an already-expired lock.", []);
|
||||
}
|
||||
|
||||
const { attempts, start } = await this._execute(
|
||||
this.scripts.extendScript,
|
||||
existing.resources,
|
||||
[existing.value, duration],
|
||||
settings
|
||||
);
|
||||
|
||||
// Invalidate the existing lock.
|
||||
existing.expiration = 0;
|
||||
|
||||
// Add 2 milliseconds to the drift to account for Redis expires precision,
|
||||
// which is 1 ms, plus the configured allowable drift factor.
|
||||
const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2;
|
||||
|
||||
const replacement = new Lock(this, existing.resources, existing.value, attempts, start + duration - drift);
|
||||
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a script on all clients. The resulting promise is resolved or
|
||||
* rejected as soon as this quorum is reached; the resolution or rejection
|
||||
* will contains a `stats` property that is resolved once all votes are in.
|
||||
*/
|
||||
private async _execute(
|
||||
script: { value: string; hash: string },
|
||||
keys: string[],
|
||||
args: (string | number)[],
|
||||
_settings?: Partial<Settings>
|
||||
): Promise<ExecutionResult> {
|
||||
const settings = _settings
|
||||
? {
|
||||
...this.settings,
|
||||
..._settings
|
||||
}
|
||||
: this.settings;
|
||||
|
||||
// For the purpose of easy config serialization, we treat a retryCount of
|
||||
// -1 a equivalent to Infinity.
|
||||
const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1;
|
||||
|
||||
const attempts: Promise<ExecutionStats>[] = [];
|
||||
|
||||
while (true) {
|
||||
const { vote, stats, start } = await this._attemptOperation(script, keys, args);
|
||||
|
||||
attempts.push(stats);
|
||||
|
||||
// The operation achieved a quorum in favor.
|
||||
if (vote === "for") {
|
||||
return { attempts, start };
|
||||
}
|
||||
|
||||
// Wait before reattempting.
|
||||
if (attempts.length < maxAttempts) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(
|
||||
resolve,
|
||||
Math.max(0, settings.retryDelay + Math.floor((Math.random() * 2 - 1) * settings.retryJitter)),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
} else {
|
||||
throw new ExecutionError("The operation was unable to achieve a quorum during its retry window.", attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _attemptOperation(
|
||||
script: { value: string; hash: string },
|
||||
keys: string[],
|
||||
args: (string | number)[]
|
||||
): Promise<
|
||||
| { vote: "for"; stats: Promise<ExecutionStats>; start: number }
|
||||
| { vote: "against"; stats: Promise<ExecutionStats>; start: number }
|
||||
> {
|
||||
const start = Date.now();
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const clientResults = [];
|
||||
for (const client of this.clients) {
|
||||
clientResults.push(this._attemptOperationOnClient(client, script, keys, args));
|
||||
}
|
||||
|
||||
const stats: ExecutionStats = {
|
||||
membershipSize: clientResults.length,
|
||||
quorumSize: Math.floor(clientResults.length / 2) + 1,
|
||||
votesFor: new Set<Client>(),
|
||||
votesAgainst: new Map<Client, Error>()
|
||||
};
|
||||
|
||||
let done: () => void;
|
||||
const statsPromise = new Promise<typeof stats>((resolve) => {
|
||||
done = () => resolve(stats);
|
||||
});
|
||||
|
||||
// This is the expected flow for all successful and unsuccessful requests.
|
||||
const onResultResolve = (clientResult: ClientExecutionResult): void => {
|
||||
switch (clientResult.vote) {
|
||||
case "for":
|
||||
stats.votesFor.add(clientResult.client);
|
||||
break;
|
||||
case "against":
|
||||
stats.votesAgainst.set(clientResult.client, clientResult.error);
|
||||
break;
|
||||
}
|
||||
|
||||
// A quorum has determined a success.
|
||||
if (stats.votesFor.size === stats.quorumSize) {
|
||||
resolve({
|
||||
vote: "for",
|
||||
stats: statsPromise,
|
||||
start
|
||||
});
|
||||
}
|
||||
|
||||
// A quorum has determined a failure.
|
||||
if (stats.votesAgainst.size === stats.quorumSize) {
|
||||
resolve({
|
||||
vote: "against",
|
||||
stats: statsPromise,
|
||||
start
|
||||
});
|
||||
}
|
||||
|
||||
// All votes are in.
|
||||
if (stats.votesFor.size + stats.votesAgainst.size === stats.membershipSize) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
// This is unexpected and should crash to prevent undefined behavior.
|
||||
const onResultReject = (error: Error): void => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
for (const result of clientResults) {
|
||||
result.then(onResultResolve, onResultReject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _attemptOperationOnClient(
|
||||
client: Client,
|
||||
script: { value: string; hash: string },
|
||||
keys: string[],
|
||||
args: (string | number)[]
|
||||
): Promise<ClientExecutionResult> {
|
||||
try {
|
||||
let result: number;
|
||||
try {
|
||||
// Attempt to evaluate the script by its hash.
|
||||
// @ts-expect-error
|
||||
const shaResult = (await client.evalsha(script.hash, keys.length, [...keys, ...args])) as unknown;
|
||||
|
||||
if (typeof shaResult !== "number") {
|
||||
throw new Error(`Unexpected result of type ${typeof shaResult} returned from redis.`);
|
||||
}
|
||||
|
||||
result = shaResult;
|
||||
} catch (error) {
|
||||
// If the redis server does not already have the script cached,
|
||||
// reattempt the request with the script's raw text.
|
||||
if (!(error instanceof Error) || !error.message.startsWith("NOSCRIPT")) {
|
||||
throw error;
|
||||
}
|
||||
// @ts-expect-error
|
||||
const rawResult = (await client.eval(script.value, keys.length, [...keys, ...args])) as unknown;
|
||||
|
||||
if (typeof rawResult !== "number") {
|
||||
throw new Error(`Unexpected result of type ${typeof rawResult} returned from redis.`);
|
||||
}
|
||||
|
||||
result = rawResult;
|
||||
}
|
||||
|
||||
// One or more of the resources was already locked.
|
||||
if (result !== keys.length) {
|
||||
throw new ResourceLockedError(
|
||||
`The operation was applied to: ${result} of the ${keys.length} requested resources.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
vote: "for",
|
||||
client,
|
||||
value: result
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`);
|
||||
}
|
||||
|
||||
// Emit the error on the redlock instance for observability.
|
||||
this.emit("error", error);
|
||||
|
||||
return {
|
||||
vote: "against",
|
||||
client,
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap and execute a routine in the context of an auto-extending lock,
|
||||
* returning a promise of the routine's value. In the case that auto-extension
|
||||
* fails, an AbortSignal will be updated to indicate that abortion of the
|
||||
* routine is in order, and to pass along the encountered error.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await redlock.using([senderId, recipientId], 5000, { retryCount: 5 }, async (signal) => {
|
||||
* const senderBalance = await getBalance(senderId);
|
||||
* const recipientBalance = await getBalance(recipientId);
|
||||
*
|
||||
* if (senderBalance < amountToSend) {
|
||||
* throw new Error("Insufficient balance.");
|
||||
* }
|
||||
*
|
||||
* // The abort signal will be true if:
|
||||
* // 1. the above took long enough that the lock needed to be extended
|
||||
* // 2. redlock was unable to extend the lock
|
||||
* //
|
||||
* // In such a case, exclusivity can no longer be guaranteed for further
|
||||
* // operations, and should be handled as an exceptional case.
|
||||
* if (signal.aborted) {
|
||||
* throw signal.error;
|
||||
* }
|
||||
*
|
||||
* await setBalances([
|
||||
* {id: senderId, balance: senderBalance - amountToSend},
|
||||
* {id: recipientId, balance: recipientBalance + amountToSend},
|
||||
* ]);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
public async using<T>(
|
||||
resources: string[],
|
||||
duration: number,
|
||||
settings: Partial<Settings>,
|
||||
routine?: (signal: RedlockAbortSignal) => Promise<T>
|
||||
): Promise<T>;
|
||||
|
||||
public async using<T>(
|
||||
resources: string[],
|
||||
duration: number,
|
||||
routine: (signal: RedlockAbortSignal) => Promise<T>
|
||||
): Promise<T>;
|
||||
|
||||
public async using<T>(
|
||||
resources: string[],
|
||||
duration: number,
|
||||
settingsOrRoutine: undefined | Partial<Settings> | ((signal: RedlockAbortSignal) => Promise<T>),
|
||||
optionalRoutine?: (signal: RedlockAbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (Math.floor(duration) !== duration) {
|
||||
throw new Error("Duration must be an integer value in milliseconds.");
|
||||
}
|
||||
|
||||
const settings =
|
||||
settingsOrRoutine && typeof settingsOrRoutine !== "function"
|
||||
? {
|
||||
...this.settings,
|
||||
...settingsOrRoutine
|
||||
}
|
||||
: this.settings;
|
||||
|
||||
const routine = optionalRoutine ?? settingsOrRoutine;
|
||||
if (typeof routine !== "function") {
|
||||
throw new Error("INVARIANT: routine is not a function.");
|
||||
}
|
||||
|
||||
if (settings.automaticExtensionThreshold > duration - 100) {
|
||||
throw new Error(
|
||||
"A lock `duration` must be at least 100ms greater than the `automaticExtensionThreshold` setting."
|
||||
);
|
||||
}
|
||||
|
||||
// The AbortController/AbortSignal pattern allows the routine to be notified
|
||||
// of a failure to extend the lock, and subsequent expiration. In the event
|
||||
// of an abort, the error object will be made available at `signal.error`.
|
||||
const controller = new AbortController();
|
||||
|
||||
const signal = controller.signal as RedlockAbortSignal;
|
||||
|
||||
function queue(): void {
|
||||
timeout = setTimeout(
|
||||
() => (extension = extend()),
|
||||
lock.expiration - Date.now() - settings.automaticExtensionThreshold
|
||||
);
|
||||
}
|
||||
|
||||
async function extend(): Promise<void> {
|
||||
timeout = undefined;
|
||||
|
||||
try {
|
||||
lock = await lock.extend(duration);
|
||||
queue();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error(`Unexpected thrown ${typeof error}: ${error}.`);
|
||||
}
|
||||
|
||||
if (lock.expiration > Date.now()) {
|
||||
return (extension = extend());
|
||||
}
|
||||
|
||||
signal.error = error instanceof Error ? error : new Error(`${error}`);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: undefined | NodeJS.Timeout;
|
||||
let extension: undefined | Promise<void>;
|
||||
let lock = await this.acquire(resources, duration, settings);
|
||||
queue();
|
||||
|
||||
try {
|
||||
return await routine(signal);
|
||||
} finally {
|
||||
// Clean up the timer.
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
// Wait for an in-flight extension to finish.
|
||||
if (extension) {
|
||||
await extension.catch(() => {
|
||||
// An error here doesn't matter at all, because the routine has
|
||||
// already completed, and a release will be attempted regardless. The
|
||||
// only reason for waiting here is to prevent possible contention
|
||||
// between the extension and release.
|
||||
});
|
||||
}
|
||||
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
@ -7,3 +7,7 @@ export const zpStr = <T extends ZodTypeAny>(schema: T, opt: { stripNull: boolean
|
||||
if (typeof val !== "string") return val;
|
||||
return val.trim() || undefined;
|
||||
}, schema);
|
||||
|
||||
export const zodBuffer = z.custom<Buffer>((data) => Buffer.isBuffer(data) || data instanceof Uint8Array, {
|
||||
message: "Expected binary data (Buffer Or Uint8Array)"
|
||||
});
|
||||
|
@ -7,33 +7,42 @@ import {
|
||||
TScanFullRepoEventPayload,
|
||||
TScanPushEventPayload
|
||||
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
|
||||
import { TSyncSecretsDTO } from "@app/services/secret/secret-types";
|
||||
|
||||
export enum QueueName {
|
||||
SecretRotation = "secret-rotation",
|
||||
SecretReminder = "secret-reminder",
|
||||
AuditLog = "audit-log",
|
||||
// TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue
|
||||
AuditLogPrune = "audit-log-prune",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||
IntegrationSync = "sync-integrations",
|
||||
SecretWebhook = "secret-webhook",
|
||||
SecretFullRepoScan = "secret-full-repo-scan",
|
||||
SecretPushEventScan = "secret-push-event-scan",
|
||||
UpgradeProjectToGhost = "upgrade-project-to-ghost",
|
||||
DynamicSecretRevocation = "dynamic-secret-revocation"
|
||||
DynamicSecretRevocation = "dynamic-secret-revocation",
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
SecretReminder = "secret-reminder-job",
|
||||
SecretRotation = "secret-rotation-job",
|
||||
AuditLog = "audit-log-job",
|
||||
// TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue
|
||||
AuditLogPrune = "audit-log-prune-job",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
||||
SecWebhook = "secret-webhook-trigger",
|
||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||
IntegrationSync = "secret-integration-pull",
|
||||
SecretScan = "secret-scan",
|
||||
UpgradeProjectToGhost = "upgrade-project-to-ghost-job",
|
||||
DynamicSecretRevocation = "dynamic-secret-revocation",
|
||||
DynamicSecretPruning = "dynamic-secret-pruning"
|
||||
DynamicSecretPruning = "dynamic-secret-pruning",
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@ -55,6 +64,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.AuditLog;
|
||||
payload: TCreateAuditLogDTO;
|
||||
};
|
||||
[QueueName.DailyResourceCleanUp]: {
|
||||
name: QueueJobs.DailyResourceCleanUp;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.AuditLogPrune]: {
|
||||
name: QueueJobs.AuditLogPrune;
|
||||
payload: undefined;
|
||||
@ -108,6 +121,14 @@ export type TQueueJobTypes = {
|
||||
dynamicSecretCfgId: string;
|
||||
};
|
||||
};
|
||||
[QueueName.SecretReplication]: {
|
||||
name: QueueJobs.SecretReplication;
|
||||
payload: TSyncSecretsDTO;
|
||||
};
|
||||
[QueueName.SecretSync]: {
|
||||
name: QueueJobs.SecretSync;
|
||||
payload: TSyncSecretsDTO;
|
||||
};
|
||||
};
|
||||
|
||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||
@ -124,7 +145,7 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
|
||||
const start = <T extends QueueName>(
|
||||
name: T,
|
||||
jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>) => Promise<void>,
|
||||
jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>, token?: string) => Promise<void>,
|
||||
queueSettings: Omit<QueueOptions, "connection"> = {}
|
||||
) => {
|
||||
if (queueContainer[name]) {
|
||||
@ -158,7 +179,7 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
name: T,
|
||||
job: TQueueJobTypes[T]["name"],
|
||||
data: TQueueJobTypes[T]["payload"],
|
||||
opts: JobsOptions & { jobId?: string }
|
||||
opts?: JobsOptions & { jobId?: string }
|
||||
) => {
|
||||
const q = queueContainer[name];
|
||||
|
||||
@ -172,7 +193,9 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
jobId?: string
|
||||
) => {
|
||||
const q = queueContainer[name];
|
||||
return q.removeRepeatable(job, repeatOpt, jobId);
|
||||
if (q) {
|
||||
return q.removeRepeatable(job, repeatOpt, jobId);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRepeatableJobByJobId = async <T extends QueueName>(name: T, jobId: string) => {
|
||||
|
@ -28,7 +28,7 @@ export const readLimit: RateLimitOptions = {
|
||||
// POST, PATCH, PUT, DELETE endpoints
|
||||
export const writeLimit: RateLimitOptions = {
|
||||
timeWindow: 60 * 1000,
|
||||
max: 50,
|
||||
max: 200, // (too low, FA having issues so increasing it - maidul)
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
||||
@ -52,9 +52,25 @@ export const inviteUserRateLimit: RateLimitOptions = {
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
||||
export const mfaRateLimit: RateLimitOptions = {
|
||||
timeWindow: 60 * 1000,
|
||||
max: 20,
|
||||
keyGenerator: (req) => {
|
||||
return req.headers.authorization?.split(" ")[1] || req.realIp;
|
||||
}
|
||||
};
|
||||
|
||||
export const creationLimit: RateLimitOptions = {
|
||||
// identity, project, org
|
||||
timeWindow: 60 * 1000,
|
||||
max: 30,
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
||||
// Public endpoints to avoid brute force attacks
|
||||
export const publicEndpointLimit: RateLimitOptions = {
|
||||
// Shared Secrets
|
||||
timeWindow: 60 * 1000,
|
||||
max: 30,
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ const headersOrder = [
|
||||
"cf-connecting-ip", // Cloudflare
|
||||
"Cf-Pseudo-IPv4", // Cloudflare
|
||||
"x-client-ip", // Most common
|
||||
"x-envoy-external-address", // for envoy
|
||||
"x-forwarded-for", // Mostly used by proxies
|
||||
"fastly-client-ip",
|
||||
"true-client-ip", // Akamai and Cloudflare
|
||||
@ -23,7 +24,21 @@ export const fastifyIp = fp(async (fastify) => {
|
||||
const forwardedIpHeader = headersOrder.find((header) => Boolean(req.headers[header]));
|
||||
const forwardedIp = forwardedIpHeader ? req.headers[forwardedIpHeader] : undefined;
|
||||
if (forwardedIp) {
|
||||
req.realIp = Array.isArray(forwardedIp) ? forwardedIp[0] : forwardedIp;
|
||||
if (Array.isArray(forwardedIp)) {
|
||||
// eslint-disable-next-line
|
||||
req.realIp = forwardedIp[0];
|
||||
return;
|
||||
}
|
||||
|
||||
if (forwardedIp.includes(",")) {
|
||||
// the ip header when placed with load balancers that proxy request
|
||||
// will attach the internal ips to header by appending with comma
|
||||
// https://github.com/go-chi/chi/blob/master/middleware/realip.go
|
||||
const clientIPFromProxy = forwardedIp.slice(0, forwardedIp.indexOf(",")).trim();
|
||||
req.realIp = clientIPFromProxy;
|
||||
return;
|
||||
}
|
||||
req.realIp = forwardedIp;
|
||||
} else {
|
||||
req.realIp = req.ip;
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approva
|
||||
import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal";
|
||||
import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
|
||||
import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
|
||||
import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service";
|
||||
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
|
||||
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
|
||||
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
||||
@ -80,6 +81,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok
|
||||
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
|
||||
import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal";
|
||||
import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service";
|
||||
import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal";
|
||||
import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service";
|
||||
import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal";
|
||||
import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service";
|
||||
import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal";
|
||||
@ -94,6 +97,9 @@ import { integrationDALFactory } from "@app/services/integration/integration-dal
|
||||
import { integrationServiceFactory } from "@app/services/integration/integration-service";
|
||||
import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal";
|
||||
import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
|
||||
import { kmsDALFactory } from "@app/services/kms/kms-dal";
|
||||
import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal";
|
||||
import { kmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal";
|
||||
import { orgBotDALFactory } from "@app/services/org/org-bot-dal";
|
||||
import { orgDALFactory } from "@app/services/org/org-dal";
|
||||
@ -115,6 +121,7 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi
|
||||
import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal";
|
||||
import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal";
|
||||
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
|
||||
import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue";
|
||||
import { secretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import { secretQueueFactory } from "@app/services/secret/secret-queue";
|
||||
import { secretServiceFactory } from "@app/services/secret/secret-service";
|
||||
@ -127,6 +134,8 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f
|
||||
import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal";
|
||||
import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
|
||||
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
|
||||
import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||
import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||
import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal";
|
||||
@ -212,8 +221,8 @@ export const registerRoutes = async (
|
||||
const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db);
|
||||
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
|
||||
const identityAwsAuthDAL = identityAwsAuthDALFactory(db);
|
||||
|
||||
const identityGcpAuthDAL = identityGcpAuthDALFactory(db);
|
||||
const identityAzureAuthDAL = identityAzureAuthDALFactory(db);
|
||||
|
||||
const auditLogDAL = auditLogDALFactory(db);
|
||||
const auditLogStreamDAL = auditLogStreamDALFactory(db);
|
||||
@ -235,8 +244,8 @@ export const registerRoutes = async (
|
||||
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
|
||||
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
|
||||
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
|
||||
const sarReviewerDAL = secretApprovalRequestReviewerDALFactory(db);
|
||||
const sarSecretDAL = secretApprovalRequestSecretDALFactory(db);
|
||||
const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db);
|
||||
const secretApprovalRequestSecretDAL = secretApprovalRequestSecretDALFactory(db);
|
||||
|
||||
const secretRotationDAL = secretRotationDALFactory(db);
|
||||
const snapshotDAL = snapshotDALFactory(db);
|
||||
@ -250,10 +259,14 @@ export const registerRoutes = async (
|
||||
const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db);
|
||||
const userGroupMembershipDAL = userGroupMembershipDALFactory(db);
|
||||
const secretScanningDAL = secretScanningDALFactory(db);
|
||||
const secretSharingDAL = secretSharingDALFactory(db);
|
||||
const licenseDAL = licenseDALFactory(db);
|
||||
const dynamicSecretDAL = dynamicSecretDALFactory(db);
|
||||
const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
|
||||
|
||||
const kmsDAL = kmsDALFactory(db);
|
||||
const kmsRootConfigDAL = kmsRootConfigDALFactory(db);
|
||||
|
||||
const permissionService = permissionServiceFactory({
|
||||
permissionDAL,
|
||||
orgRoleDAL,
|
||||
@ -262,6 +275,12 @@ export const registerRoutes = async (
|
||||
projectDAL
|
||||
});
|
||||
const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore });
|
||||
const kmsService = kmsServiceFactory({
|
||||
kmsRootConfigDAL,
|
||||
keyStore,
|
||||
kmsDAL
|
||||
});
|
||||
|
||||
const trustedIpService = trustedIpServiceFactory({
|
||||
licenseService,
|
||||
projectDAL,
|
||||
@ -282,7 +301,7 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
auditLogStreamDAL
|
||||
});
|
||||
const sapService = secretApprovalPolicyServiceFactory({
|
||||
const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({
|
||||
projectMembershipDAL,
|
||||
projectEnvDAL,
|
||||
secretApprovalPolicyApproverDAL: sapApproverDAL,
|
||||
@ -483,7 +502,7 @@ export const registerRoutes = async (
|
||||
projectBotDAL,
|
||||
projectMembershipDAL,
|
||||
secretApprovalRequestDAL,
|
||||
secretApprovalSecretDAL: sarSecretDAL,
|
||||
secretApprovalSecretDAL: secretApprovalRequestSecretDAL,
|
||||
projectUserMembershipRoleDAL
|
||||
});
|
||||
|
||||
@ -520,7 +539,8 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
projectRoleDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
identityProjectMembershipRoleDAL
|
||||
identityProjectMembershipRoleDAL,
|
||||
projectDAL
|
||||
});
|
||||
|
||||
const snapshotService = secretSnapshotServiceFactory({
|
||||
@ -580,6 +600,7 @@ export const registerRoutes = async (
|
||||
secretVersionTagDAL
|
||||
});
|
||||
const secretImportService = secretImportServiceFactory({
|
||||
licenseService,
|
||||
projectEnvDAL,
|
||||
folderDAL,
|
||||
permissionService,
|
||||
@ -608,19 +629,24 @@ export const registerRoutes = async (
|
||||
projectEnvDAL,
|
||||
projectBotService
|
||||
});
|
||||
const sarService = secretApprovalRequestServiceFactory({
|
||||
|
||||
const secretSharingService = secretSharingServiceFactory({
|
||||
permissionService,
|
||||
secretSharingDAL
|
||||
});
|
||||
|
||||
const secretApprovalRequestService = secretApprovalRequestServiceFactory({
|
||||
permissionService,
|
||||
projectBotService,
|
||||
folderDAL,
|
||||
secretDAL,
|
||||
secretTagDAL,
|
||||
secretApprovalRequestSecretDAL: sarSecretDAL,
|
||||
secretApprovalRequestReviewerDAL: sarReviewerDAL,
|
||||
secretApprovalRequestSecretDAL,
|
||||
secretApprovalRequestReviewerDAL,
|
||||
projectDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretApprovalRequestDAL,
|
||||
secretService,
|
||||
snapshotService,
|
||||
secretVersionTagDAL,
|
||||
secretQueueService
|
||||
@ -649,6 +675,23 @@ export const registerRoutes = async (
|
||||
accessApprovalPolicyApproverDAL
|
||||
});
|
||||
|
||||
const secretReplicationService = secretReplicationServiceFactory({
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
secretDAL,
|
||||
secretVersionDAL,
|
||||
secretImportDAL,
|
||||
keyStore,
|
||||
queueService,
|
||||
folderDAL,
|
||||
secretApprovalPolicyService,
|
||||
secretBlindIndexDAL,
|
||||
secretApprovalRequestDAL,
|
||||
secretApprovalRequestSecretDAL,
|
||||
secretQueueService,
|
||||
projectMembershipDAL,
|
||||
projectBotService
|
||||
});
|
||||
const secretRotationQueue = secretRotationQueueFactory({
|
||||
telemetryService,
|
||||
secretRotationDAL,
|
||||
@ -742,6 +785,15 @@ export const registerRoutes = async (
|
||||
permissionService
|
||||
});
|
||||
|
||||
const identityAzureAuthService = identityAzureAuthServiceFactory({
|
||||
identityAzureAuthDAL,
|
||||
identityOrgMembershipDAL,
|
||||
identityAccessTokenDAL,
|
||||
identityDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const dynamicSecretProviders = buildDynamicSecretProviders();
|
||||
const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({
|
||||
queueService,
|
||||
@ -769,14 +821,21 @@ export const registerRoutes = async (
|
||||
folderDAL,
|
||||
licenseService
|
||||
});
|
||||
const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL
|
||||
});
|
||||
|
||||
await superAdminService.initServerCfg();
|
||||
//
|
||||
// setup the communication with license key server
|
||||
await licenseService.init();
|
||||
|
||||
await auditLogQueue.startAuditLogPruneJob();
|
||||
await telemetryQueue.startTelemetryCheck();
|
||||
await dailyResourceCleanUp.startCleanUp();
|
||||
await kmsService.startService();
|
||||
|
||||
// inject all services
|
||||
server.decorate<FastifyZodProvider["services"]>("services", {
|
||||
@ -798,6 +857,7 @@ export const registerRoutes = async (
|
||||
projectEnv: projectEnvService,
|
||||
projectRole: projectRoleService,
|
||||
secret: secretService,
|
||||
secretReplication: secretReplicationService,
|
||||
secretTag: secretTagService,
|
||||
folder: folderService,
|
||||
secretImport: secretImportService,
|
||||
@ -813,10 +873,11 @@ export const registerRoutes = async (
|
||||
identityKubernetesAuth: identityKubernetesAuthService,
|
||||
identityGcpAuth: identityGcpAuthService,
|
||||
identityAwsAuth: identityAwsAuthService,
|
||||
secretApprovalPolicy: sapService,
|
||||
identityAzureAuth: identityAzureAuthService,
|
||||
accessApprovalPolicy: accessApprovalPolicyService,
|
||||
accessApprovalRequest: accessApprovalRequestService,
|
||||
secretApprovalRequest: sarService,
|
||||
secretApprovalPolicy: secretApprovalPolicyService,
|
||||
secretApprovalRequest: secretApprovalRequestService,
|
||||
secretRotation: secretRotationService,
|
||||
dynamicSecret: dynamicSecretService,
|
||||
dynamicSecretLease: dynamicSecretLeaseService,
|
||||
@ -832,7 +893,8 @@ export const registerRoutes = async (
|
||||
secretBlindIndex: secretBlindIndexService,
|
||||
telemetry: telemetryService,
|
||||
projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService,
|
||||
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService
|
||||
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService,
|
||||
secretSharing: secretSharingService
|
||||
});
|
||||
|
||||
server.decorate<FastifyZodProvider["store"]>("store", {
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
DynamicSecretsSchema,
|
||||
IdentityProjectAdditionalPrivilegeSchema,
|
||||
IntegrationAuthsSchema,
|
||||
ProjectRolesSchema,
|
||||
SecretApprovalPoliciesSchema,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
@ -88,10 +89,38 @@ export const ProjectPermissionSchema = z.object({
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const ProjectSpecificPrivilegePermissionSchema = z.object({
|
||||
actions: z
|
||||
.nativeEnum(ProjectPermissionActions)
|
||||
.describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read")
|
||||
.array()
|
||||
.min(1),
|
||||
subject: z
|
||||
.enum([ProjectPermissionSub.Secrets])
|
||||
.describe("The entity this permission pertains to. Possible options: secrets, environments"),
|
||||
conditions: z
|
||||
.object({
|
||||
environment: z.string().describe("The environment slug this permission should allow."),
|
||||
secretPath: z
|
||||
.object({
|
||||
$glob: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ")
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.describe("When specified, only matching conditions will be allowed to access given resource.")
|
||||
});
|
||||
|
||||
export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({
|
||||
permissions: UnpackedPermissionSchema.array()
|
||||
});
|
||||
|
||||
export const SanitizedRoleSchema = ProjectRolesSchema.extend({
|
||||
permissions: UnpackedPermissionSchema.array()
|
||||
});
|
||||
|
||||
export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
|
||||
inputIV: true,
|
||||
inputTag: true,
|
||||
|
262
backend/src/server/routes/v1/identity-azure-auth-router.ts
Normal file
262
backend/src/server/routes/v1/identity-azure-auth-router.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityAzureAuthsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||
import { validateAzureAuthField } from "@app/services/identity-azure-auth/identity-azure-auth-validators";
|
||||
|
||||
export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/azure-auth/login",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Login with Azure Auth",
|
||||
body: z.object({
|
||||
identityId: z.string(),
|
||||
jwt: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
accessToken: z.string(),
|
||||
expiresIn: z.coerce.number(),
|
||||
accessTokenMaxTTL: z.coerce.number(),
|
||||
tokenType: z.literal("Bearer")
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } =
|
||||
await server.services.identityAzureAuth.login(req.body);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: identityMembershipOrg.orgId,
|
||||
event: {
|
||||
type: EventType.LOGIN_IDENTITY_AZURE_AUTH,
|
||||
metadata: {
|
||||
identityId: identityAzureAuth.identityId,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
identityAzureAuthId: identityAzureAuth.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
tokenType: "Bearer" as const,
|
||||
expiresIn: identityAzureAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/azure-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Attach Azure Auth configuration onto identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
tenantId: z.string().trim(),
|
||||
resource: z.string().trim(),
|
||||
allowedServicePrincipalIds: validateAzureAuthField,
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]),
|
||||
accessTokenTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000),
|
||||
accessTokenMaxTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenMaxTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000),
|
||||
accessTokenNumUsesLimit: z.number().int().min(0).default(0)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityAzureAuth: IdentityAzureAuthsSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityAzureAuth = await server.services.identityAzureAuth.attachAzureAuth({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body,
|
||||
identityId: req.params.identityId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: identityAzureAuth.orgId,
|
||||
event: {
|
||||
type: EventType.ADD_IDENTITY_AZURE_AUTH,
|
||||
metadata: {
|
||||
identityId: identityAzureAuth.identityId,
|
||||
tenantId: identityAzureAuth.tenantId,
|
||||
resource: identityAzureAuth.resource,
|
||||
accessTokenTTL: identityAzureAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityAzureAuth };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/azure-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Update Azure Auth configuration on identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
tenantId: z.string().trim().optional(),
|
||||
resource: z.string().trim().optional(),
|
||||
allowedServicePrincipalIds: validateAzureAuthField.optional(),
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
accessTokenTTL: z.number().int().min(0).optional(),
|
||||
accessTokenNumUsesLimit: z.number().int().min(0).optional(),
|
||||
accessTokenMaxTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenMaxTTL must have a non zero number"
|
||||
})
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityAzureAuth: IdentityAzureAuthsSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityAzureAuth = await server.services.identityAzureAuth.updateAzureAuth({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
...req.body,
|
||||
identityId: req.params.identityId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: identityAzureAuth.orgId,
|
||||
event: {
|
||||
type: EventType.UPDATE_IDENTITY_AZURE_AUTH,
|
||||
metadata: {
|
||||
identityId: identityAzureAuth.identityId,
|
||||
tenantId: identityAzureAuth.tenantId,
|
||||
resource: identityAzureAuth.resource,
|
||||
accessTokenTTL: identityAzureAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityAzureAuth };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/azure-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Retrieve Azure Auth configuration on identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityAzureAuth: IdentityAzureAuthsSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityAzureAuth = await server.services.identityAzureAuth.getAzureAuth({
|
||||
identityId: req.params.identityId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: identityAzureAuth.orgId,
|
||||
event: {
|
||||
type: EventType.GET_IDENTITY_AZURE_AUTH,
|
||||
metadata: {
|
||||
identityId: identityAzureAuth.identityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityAzureAuth };
|
||||
}
|
||||
});
|
||||
};
|
@ -160,9 +160,9 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider)
|
||||
}),
|
||||
body: z.object({
|
||||
type: z.enum(["iam", "gce"]).optional(),
|
||||
allowedServiceAccounts: validateGcpAuthField,
|
||||
allowedProjects: validateGcpAuthField,
|
||||
allowedZones: validateGcpAuthField,
|
||||
allowedServiceAccounts: validateGcpAuthField.optional(),
|
||||
allowedProjects: validateGcpAuthField.optional(),
|
||||
allowedZones: validateGcpAuthField.optional(),
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
|
@ -3,6 +3,7 @@ import { registerAuthRoutes } from "./auth-router";
|
||||
import { registerProjectBotRouter } from "./bot-router";
|
||||
import { registerIdentityAccessTokenRouter } from "./identity-access-token-router";
|
||||
import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router";
|
||||
import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router";
|
||||
import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router";
|
||||
import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router";
|
||||
import { registerIdentityRouter } from "./identity-router";
|
||||
@ -18,6 +19,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||
import { registerSecretImportRouter } from "./secret-import-router";
|
||||
import { registerSecretSharingRouter } from "./secret-sharing-router";
|
||||
import { registerSecretTagRouter } from "./secret-tag-router";
|
||||
import { registerSsoRouter } from "./sso-router";
|
||||
import { registerUserActionRouter } from "./user-action-router";
|
||||
@ -34,6 +36,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await authRouter.register(registerIdentityGcpAuthRouter);
|
||||
await authRouter.register(registerIdentityAccessTokenRouter);
|
||||
await authRouter.register(registerIdentityAwsAuthRouter);
|
||||
await authRouter.register(registerIdentityAzureAuthRouter);
|
||||
},
|
||||
{ prefix: "/auth" }
|
||||
);
|
||||
@ -63,4 +66,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
|
||||
await server.register(registerWebhookRouter, { prefix: "/webhooks" });
|
||||
await server.register(registerIdentityRouter, { prefix: "/identities" });
|
||||
await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" });
|
||||
};
|
||||
|
@ -330,7 +330,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
|
||||
teams: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
id: z.string().optional()
|
||||
id: z.string()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
|
@ -42,6 +42,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
|
||||
targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService),
|
||||
targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId),
|
||||
owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner),
|
||||
url: z.string().trim().optional().describe(INTEGRATION.CREATE.url),
|
||||
path: z.string().trim().optional().describe(INTEGRATION.CREATE.path),
|
||||
region: z.string().trim().optional().describe(INTEGRATION.CREATE.region),
|
||||
scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope),
|
||||
|
@ -29,7 +29,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
|
||||
import: z.object({
|
||||
environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment),
|
||||
path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path)
|
||||
})
|
||||
}),
|
||||
isReplication: z.boolean().default(false)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -210,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:secretImportId/replication-resync",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Resync secret replication of secret imports",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId)
|
||||
}),
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId),
|
||||
environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { message } = await server.services.secretImport.resyncSecretImportReplication({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.secretImportId,
|
||||
...req.body,
|
||||
projectId: req.body.workspaceId
|
||||
});
|
||||
|
||||
return { message };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
@ -232,11 +276,9 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
|
||||
200: z.object({
|
||||
message: z.string(),
|
||||
secretImports: SecretImportsSchema.omit({ importEnv: true })
|
||||
.merge(
|
||||
z.object({
|
||||
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
|
||||
})
|
||||
)
|
||||
.extend({
|
||||
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
|
||||
})
|
||||
.array()
|
||||
})
|
||||
}
|
||||
|
145
backend/src/server/routes/v1/secret-sharing-router.ts
Normal file
145
backend/src/server/routes/v1/secret-sharing-router.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingSchema } from "@app/db/schemas";
|
||||
import { publicEndpointLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerSecretSharingRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.array(SecretSharingSchema)
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return sharedSecrets;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/public/:id",
|
||||
config: {
|
||||
rateLimit: publicEndpointLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
hashedHex: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: SecretSharingSchema.pick({
|
||||
encryptedValue: true,
|
||||
iv: true,
|
||||
tag: true,
|
||||
expiresAt: true,
|
||||
expiresAfterViews: true
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex(
|
||||
req.params.id,
|
||||
req.query.hashedHex
|
||||
);
|
||||
if (!sharedSecret) return undefined;
|
||||
return {
|
||||
encryptedValue: sharedSecret.encryptedValue,
|
||||
iv: sharedSecret.iv,
|
||||
tag: sharedSecret.tag,
|
||||
expiresAt: sharedSecret.expiresAt,
|
||||
expiresAfterViews: sharedSecret.expiresAfterViews
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
|
||||
expiresAfterViews: z.number()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
id: z.string().uuid()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
|
||||
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews
|
||||
});
|
||||
return { id: sharedSecret.id };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:sharedSecretId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
sharedSecretId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: SecretSharingSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { sharedSecretId } = req.params;
|
||||
const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
sharedSecretId
|
||||
});
|
||||
|
||||
return { ...deletedSharedSecret };
|
||||
}
|
||||
});
|
||||
};
|
@ -1,11 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { authRateLimit, readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
@ -25,4 +29,29 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
return { user };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:userId/unlock",
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
token: z.string().trim()
|
||||
}),
|
||||
params: z.object({
|
||||
userId: z.string()
|
||||
})
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
await server.services.user.unlockUser(req.params.userId, req.query.token);
|
||||
} catch (err) {
|
||||
logger.error(`User unlock failed for ${req.params.userId}`);
|
||||
logger.error(err);
|
||||
}
|
||||
return res.redirect(`${appCfg.SITE_URL}/login`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { mfaRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
method: "POST",
|
||||
url: "/mfa/send",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
rateLimit: mfaRateLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
url: "/mfa/verify",
|
||||
method: "POST",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
rateLimit: mfaRateLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
ServiceTokenScopes
|
||||
} from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
|
||||
import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
@ -19,6 +18,7 @@ import { getUserAgentType } from "@app/server/plugins/audit-log";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
|
||||
import { ProjectFilterType } from "@app/services/project/project-types";
|
||||
import { SecretOperations } from "@app/services/secret/secret-types";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
import { secretRawSchema } from "../sanitizedSchemas";
|
||||
@ -902,7 +902,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Create]: [
|
||||
[SecretOperations.Create]: [
|
||||
{
|
||||
secretName: req.params.secretName,
|
||||
secretValueCiphertext,
|
||||
@ -1084,7 +1084,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Update]: [
|
||||
[SecretOperations.Update]: [
|
||||
{
|
||||
secretName: req.params.secretName,
|
||||
newSecretName,
|
||||
@ -1234,7 +1234,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Delete]: [
|
||||
[SecretOperations.Delete]: [
|
||||
{
|
||||
secretName: req.params.secretName
|
||||
}
|
||||
@ -1364,7 +1364,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Create]: inputSecrets
|
||||
[SecretOperations.Create]: inputSecrets
|
||||
}
|
||||
});
|
||||
|
||||
@ -1491,7 +1491,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Update]: inputSecrets.filter(({ type }) => type === "shared")
|
||||
[SecretOperations.Update]: inputSecrets.filter(({ type }) => type === "shared")
|
||||
}
|
||||
});
|
||||
|
||||
@ -1606,7 +1606,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
projectId,
|
||||
policy,
|
||||
data: {
|
||||
[CommitType.Delete]: inputSecrets.filter(({ type }) => type === "shared")
|
||||
[SecretOperations.Delete]: inputSecrets.filter(({ type }) => type === "shared")
|
||||
}
|
||||
});
|
||||
await server.services.auditLog.createAuditLog({
|
||||
|
@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
|
||||
|
||||
type TAuthTokenServiceFactoryDep = {
|
||||
tokenDAL: TTokenDALFactory;
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
userDAL: Pick<TUserDALFactory, "findById" | "transaction">;
|
||||
};
|
||||
|
||||
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
|
||||
|
||||
export const getTokenConfig = (tokenType: TokenType) => {
|
||||
@ -53,6 +54,11 @@ export const getTokenConfig = (tokenType: TokenType) => {
|
||||
const expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_USER_UNLOCK: {
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date(new Date().getTime() + 259200000);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
default: {
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date();
|
||||
|
@ -3,7 +3,8 @@ export enum TokenType {
|
||||
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
|
||||
TOKEN_EMAIL_MFA = "emailMfa",
|
||||
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
|
||||
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset"
|
||||
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset",
|
||||
TOKEN_USER_UNLOCK = "userUnlock"
|
||||
}
|
||||
|
||||
export type TCreateTokenForUserDTO = {
|
||||
|
@ -44,3 +44,27 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid
|
||||
if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError();
|
||||
if (decodedToken.userId !== userId) throw new UnauthorizedError();
|
||||
};
|
||||
|
||||
export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => {
|
||||
if (isLocked) {
|
||||
throw new UnauthorizedError({
|
||||
name: "User Locked",
|
||||
message:
|
||||
"User is locked due to multiple failed login attempts. An email has been sent to you in order to unlock your account. You can also reset your password to unlock your account."
|
||||
});
|
||||
}
|
||||
|
||||
if (temporaryLockDateEnd) {
|
||||
const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime();
|
||||
if (timeDiff < 0) {
|
||||
const secondsDiff = (-1 * timeDiff) / 1000;
|
||||
const timeDisplay =
|
||||
secondsDiff > 60 ? `${Math.ceil(secondsDiff / 60)} minutes` : `${Math.ceil(secondsDiff)} seconds`;
|
||||
|
||||
throw new UnauthorizedError({
|
||||
name: "User Locked",
|
||||
message: `User is temporary locked due to multiple failed login attempts. Try again after ${timeDisplay}. You can also reset your password now to proceed.`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas";
|
||||
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
|
||||
import { TTokenDALFactory } from "../auth-token/auth-token-dal";
|
||||
@ -13,7 +13,7 @@ import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { validateProviderAuthToken } from "./auth-fns";
|
||||
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
|
||||
import {
|
||||
TLoginClientProofDTO,
|
||||
TLoginGenServerPublicKeyDTO,
|
||||
@ -212,6 +212,9 @@ export const authLoginServiceFactory = ({
|
||||
});
|
||||
// send multi factor auth token if they it enabled
|
||||
if (userEnc.isMfaEnabled && userEnc.email) {
|
||||
const user = await userDAL.findById(userEnc.userId);
|
||||
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
|
||||
|
||||
const mfaToken = jwt.sign(
|
||||
{
|
||||
authMethod,
|
||||
@ -300,28 +303,111 @@ export const authLoginServiceFactory = ({
|
||||
const resendMfaToken = async (userId: string) => {
|
||||
const user = await userDAL.findById(userId);
|
||||
if (!user || !user.email) return;
|
||||
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
|
||||
await sendUserMfaCode({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
};
|
||||
|
||||
const processFailedMfaAttempt = async (userId: string) => {
|
||||
try {
|
||||
const updatedUser = await userDAL.transaction(async (tx) => {
|
||||
const PROGRESSIVE_DELAY_INTERVAL = 3;
|
||||
const user = await userDAL.updateById(userId, { $incr: { consecutiveFailedMfaAttempts: 1 } }, tx);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const progressiveDelaysInMins = [5, 30, 60];
|
||||
|
||||
// lock user when failed attempt exceeds threshold
|
||||
if (
|
||||
user.consecutiveFailedMfaAttempts &&
|
||||
user.consecutiveFailedMfaAttempts >= PROGRESSIVE_DELAY_INTERVAL * (progressiveDelaysInMins.length + 1)
|
||||
) {
|
||||
return userDAL.updateById(
|
||||
userId,
|
||||
{
|
||||
isLocked: true,
|
||||
temporaryLockDateEnd: null
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
// delay user only when failed MFA attempts is a multiple of configured delay interval
|
||||
if (user.consecutiveFailedMfaAttempts && user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) {
|
||||
const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1;
|
||||
return userDAL.updateById(
|
||||
userId,
|
||||
{
|
||||
temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000)
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Process failed MFA Attempt" });
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Multi factor authentication verification of code
|
||||
* Third step of login in which user completes with mfa
|
||||
* */
|
||||
const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => {
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId,
|
||||
code: mfaToken
|
||||
});
|
||||
const appCfg = getConfig();
|
||||
const user = await userDAL.findById(userId);
|
||||
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
|
||||
|
||||
try {
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId,
|
||||
code: mfaToken
|
||||
});
|
||||
} catch (err) {
|
||||
const updatedUser = await processFailedMfaAttempt(userId);
|
||||
if (updatedUser.isLocked) {
|
||||
if (updatedUser.email) {
|
||||
const unlockToken = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_USER_UNLOCK,
|
||||
userId: updatedUser.id
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.UnlockAccount,
|
||||
subjectLine: "Unlock your Infisical account",
|
||||
recipients: [updatedUser.email],
|
||||
substitutions: {
|
||||
token: unlockToken,
|
||||
callback_url: `${appCfg.SITE_URL}/api/v1/user/${updatedUser.id}/unlock`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload;
|
||||
|
||||
const userEnc = await userDAL.findUserEncKeyByUserId(userId);
|
||||
if (!userEnc) throw new Error("Failed to authenticate user");
|
||||
|
||||
// reset lock states
|
||||
await userDAL.updateById(userId, {
|
||||
consecutiveFailedMfaAttempts: 0,
|
||||
temporaryLockDateEnd: null
|
||||
});
|
||||
|
||||
const token = await generateUserTokens({
|
||||
user: {
|
||||
...userEnc,
|
||||
|
@ -174,6 +174,12 @@ export const authPaswordServiceFactory = ({
|
||||
salt,
|
||||
verifier
|
||||
});
|
||||
|
||||
await userDAL.updateById(userId, {
|
||||
isLocked: false,
|
||||
temporaryLockDateEnd: null,
|
||||
consecutiveFailedMfaAttempts: 0
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -39,6 +39,12 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
`${TableName.IdentityAwsAuth}.identityId`
|
||||
);
|
||||
})
|
||||
.leftJoin(TableName.IdentityAzureAuth, (qb) => {
|
||||
qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AZURE_AUTH])).andOn(
|
||||
`${TableName.Identity}.id`,
|
||||
`${TableName.IdentityAzureAuth}.identityId`
|
||||
);
|
||||
})
|
||||
.leftJoin(TableName.IdentityKubernetesAuth, (qb) => {
|
||||
qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.KUBERNETES_AUTH])).andOn(
|
||||
`${TableName.Identity}.id`,
|
||||
@ -50,6 +56,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"),
|
||||
db.ref("name").withSchema(TableName.Identity)
|
||||
)
|
||||
@ -63,6 +70,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
doc.accessTokenTrustedIpsUa ||
|
||||
doc.accessTokenTrustedIpsGcp ||
|
||||
doc.accessTokenTrustedIpsAws ||
|
||||
doc.accessTokenTrustedIpsAzure ||
|
||||
doc.accessTokenTrustedIpsK8s
|
||||
};
|
||||
} catch (error) {
|
||||
@ -70,5 +78,48 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { ...identityAccessTokenOrm, findOne };
|
||||
const removeExpiredTokens = async (tx?: Knex) => {
|
||||
try {
|
||||
const docs = (tx || db)(TableName.IdentityAccessToken)
|
||||
.where({
|
||||
isAccessTokenRevoked: true
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
void qb
|
||||
.where("accessTokenNumUsesLimit", ">", 0)
|
||||
.andWhere(
|
||||
"accessTokenNumUses",
|
||||
">=",
|
||||
db.ref("accessTokenNumUsesLimit").withSchema(TableName.IdentityAccessToken)
|
||||
);
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
void qb.where("accessTokenTTL", ">", 0).andWhere((qb2) => {
|
||||
void qb2
|
||||
.where((qb3) => {
|
||||
void qb3
|
||||
.whereNotNull("accessTokenLastRenewedAt")
|
||||
// accessTokenLastRenewedAt + convert_integer_to_seconds(accessTokenTTL) < present_date
|
||||
.andWhereRaw(
|
||||
`"${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()`
|
||||
);
|
||||
})
|
||||
.orWhere((qb3) => {
|
||||
void qb3
|
||||
.whereNull("accessTokenLastRenewedAt")
|
||||
// created + convert_integer_to_seconds(accessTokenTTL) < present_date
|
||||
.andWhereRaw(
|
||||
`"${TableName.IdentityAccessToken}"."createdAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()`
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
.delete();
|
||||
return await docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...identityAccessTokenOrm, findOne, removeExpiredTokens };
|
||||
};
|
||||
|
@ -21,17 +21,18 @@ export const identityAccessTokenServiceFactory = ({
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL
|
||||
}: TIdentityAccessTokenServiceFactoryDep) => {
|
||||
const validateAccessTokenExp = (identityAccessToken: TIdentityAccessTokens) => {
|
||||
const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => {
|
||||
const {
|
||||
id: tokenId,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUses,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenLastRenewedAt,
|
||||
accessTokenMaxTTL,
|
||||
createdAt: accessTokenCreatedAt
|
||||
} = identityAccessToken;
|
||||
|
||||
if (accessTokenNumUsesLimit > 0 && accessTokenNumUses > 0 && accessTokenNumUses >= accessTokenNumUsesLimit) {
|
||||
await identityAccessTokenDAL.deleteById(tokenId);
|
||||
throw new BadRequestError({
|
||||
message: "Unable to renew because access token number of uses limit reached"
|
||||
});
|
||||
@ -46,41 +47,26 @@ export const identityAccessTokenServiceFactory = ({
|
||||
const ttlInMilliseconds = Number(accessTokenTTL) * 1000;
|
||||
const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationDate)
|
||||
if (currentDate > expirationDate) {
|
||||
await identityAccessTokenDAL.deleteById(tokenId);
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token due to TTL expiration"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// access token has never been renewed
|
||||
const accessTokenCreated = new Date(accessTokenCreatedAt);
|
||||
const ttlInMilliseconds = Number(accessTokenTTL) * 1000;
|
||||
const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationDate)
|
||||
if (currentDate > expirationDate) {
|
||||
await identityAccessTokenDAL.deleteById(tokenId);
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token due to TTL expiration"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// max ttl checks
|
||||
if (Number(accessTokenMaxTTL) > 0) {
|
||||
const accessTokenCreated = new Date(accessTokenCreatedAt);
|
||||
const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000;
|
||||
const currentDate = new Date();
|
||||
const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationDate)
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token due to Max TTL expiration"
|
||||
});
|
||||
|
||||
const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL));
|
||||
if (extendToDate > expirationDate)
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token past its Max TTL expiration"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => {
|
||||
@ -97,7 +83,32 @@ export const identityAccessTokenServiceFactory = ({
|
||||
});
|
||||
if (!identityAccessToken) throw new UnauthorizedError();
|
||||
|
||||
validateAccessTokenExp(identityAccessToken);
|
||||
await validateAccessTokenExp(identityAccessToken);
|
||||
|
||||
const { accessTokenMaxTTL, createdAt: accessTokenCreatedAt, accessTokenTTL } = identityAccessToken;
|
||||
|
||||
// max ttl checks - will it go above max ttl
|
||||
if (Number(accessTokenMaxTTL) > 0) {
|
||||
const accessTokenCreated = new Date(accessTokenCreatedAt);
|
||||
const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000;
|
||||
const currentDate = new Date();
|
||||
const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationDate) {
|
||||
await identityAccessTokenDAL.deleteById(identityAccessToken.id);
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token due to Max TTL expiration"
|
||||
});
|
||||
}
|
||||
|
||||
const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL * 1000));
|
||||
if (extendToDate > expirationDate) {
|
||||
await identityAccessTokenDAL.deleteById(identityAccessToken.id);
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to renew MI access token past its Max TTL expiration"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedIdentityAccessToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, {
|
||||
accessTokenLastRenewedAt: new Date()
|
||||
@ -131,7 +142,7 @@ export const identityAccessTokenServiceFactory = ({
|
||||
});
|
||||
if (!identityAccessToken) throw new UnauthorizedError();
|
||||
|
||||
if (ipAddress) {
|
||||
if (ipAddress && identityAccessToken) {
|
||||
checkIPAgainstBlocklist({
|
||||
ipAddress,
|
||||
trustedIps: identityAccessToken?.accessTokenTrustedIps as TIp[]
|
||||
@ -146,7 +157,14 @@ export const identityAccessTokenServiceFactory = ({
|
||||
throw new UnauthorizedError({ message: "Identity does not belong to any organization" });
|
||||
}
|
||||
|
||||
validateAccessTokenExp(identityAccessToken);
|
||||
await validateAccessTokenExp(identityAccessToken);
|
||||
|
||||
await identityAccessTokenDAL.updateById(identityAccessToken.id, {
|
||||
accessTokenLastUsedAt: new Date(),
|
||||
$incr: {
|
||||
accessTokenNumUses: 1
|
||||
}
|
||||
});
|
||||
return { ...identityAccessToken, orgId: identityOrgMembership.orgId };
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityAzureAuthDALFactory = ReturnType<typeof identityAzureAuthDALFactory>;
|
||||
|
||||
export const identityAzureAuthDALFactory = (db: TDbClient) => {
|
||||
const azureAuthOrm = ormify(db, TableName.IdentityAzureAuth);
|
||||
return azureAuthOrm;
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
import axios from "axios";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { UnauthorizedError } from "@app/lib/errors";
|
||||
|
||||
import { TAzureAuthJwtPayload, TAzureJwksUriResponse, TDecodedAzureAuthJwt } from "./identity-azure-auth-types";
|
||||
|
||||
export const validateAzureIdentity = async ({
|
||||
tenantId,
|
||||
resource,
|
||||
jwt: azureJwt
|
||||
}: {
|
||||
tenantId: string;
|
||||
resource: string;
|
||||
jwt: string;
|
||||
}) => {
|
||||
const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`;
|
||||
|
||||
const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt;
|
||||
const { kid } = decodedJwt.header;
|
||||
|
||||
const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri);
|
||||
const signingKeys = data.keys;
|
||||
|
||||
const signingKey = signingKeys.find((key) => key.kid === kid);
|
||||
if (!signingKey) throw new UnauthorizedError();
|
||||
|
||||
const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`;
|
||||
|
||||
return jwt.verify(azureJwt, publicKey, {
|
||||
audience: resource,
|
||||
issuer: `https://sts.windows.net/${tenantId}/`
|
||||
}) as TAzureAuthJwtPayload;
|
||||
};
|
@ -0,0 +1,286 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
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 { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
|
||||
import { AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityDALFactory } from "../identity/identity-dal";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
|
||||
import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal";
|
||||
import { validateAzureIdentity } from "./identity-azure-auth-fns";
|
||||
import {
|
||||
TAttachAzureAuthDTO,
|
||||
TGetAzureAuthDTO,
|
||||
TLoginAzureAuthDTO,
|
||||
TUpdateAzureAuthDTO
|
||||
} from "./identity-azure-auth-types";
|
||||
|
||||
type TIdentityAzureAuthServiceFactoryDep = {
|
||||
identityAzureAuthDAL: Pick<TIdentityAzureAuthDALFactory, "findOne" | "transaction" | "create" | "updateById">;
|
||||
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
|
||||
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "create">;
|
||||
identityDAL: Pick<TIdentityDALFactory, "updateById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TIdentityAzureAuthServiceFactory = ReturnType<typeof identityAzureAuthServiceFactory>;
|
||||
|
||||
export const identityAzureAuthServiceFactory = ({
|
||||
identityAzureAuthDAL,
|
||||
identityOrgMembershipDAL,
|
||||
identityAccessTokenDAL,
|
||||
identityDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TIdentityAzureAuthServiceFactoryDep) => {
|
||||
const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => {
|
||||
const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId });
|
||||
if (!identityAzureAuth) throw new UnauthorizedError();
|
||||
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId });
|
||||
if (!identityMembershipOrg) throw new UnauthorizedError();
|
||||
|
||||
const azureIdentity = await validateAzureIdentity({
|
||||
tenantId: identityAzureAuth.tenantId,
|
||||
resource: identityAzureAuth.resource,
|
||||
jwt: azureJwt
|
||||
});
|
||||
|
||||
if (azureIdentity.tid !== identityAzureAuth.tenantId) throw new UnauthorizedError();
|
||||
|
||||
if (identityAzureAuth.allowedServicePrincipalIds) {
|
||||
// validate if the service principal id is in the list of allowed service principal ids
|
||||
|
||||
const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds
|
||||
.split(",")
|
||||
.map((servicePrincipalId) => servicePrincipalId.trim())
|
||||
.some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid);
|
||||
|
||||
if (!isServicePrincipalAllowed) throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => {
|
||||
const newToken = await identityAccessTokenDAL.create(
|
||||
{
|
||||
identityId: identityAzureAuth.identityId,
|
||||
isAccessTokenRevoked: false,
|
||||
accessTokenTTL: identityAzureAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL,
|
||||
accessTokenNumUses: 0,
|
||||
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newToken;
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
identityId: identityAzureAuth.identityId,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||
} as TIdentityAccessTokenJwtPayload,
|
||||
appCfg.AUTH_SECRET,
|
||||
{
|
||||
expiresIn:
|
||||
Number(identityAccessToken.accessTokenMaxTTL) === 0
|
||||
? undefined
|
||||
: Number(identityAccessToken.accessTokenMaxTTL)
|
||||
}
|
||||
);
|
||||
|
||||
return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg };
|
||||
};
|
||||
|
||||
const attachAzureAuth = async ({
|
||||
identityId,
|
||||
tenantId,
|
||||
resource,
|
||||
allowedServicePrincipalIds,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TAttachAzureAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity.authMethod)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to add Azure Auth to already configured identity"
|
||||
});
|
||||
|
||||
if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
|
||||
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
accessTokenTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
const identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => {
|
||||
const doc = await identityAzureAuthDAL.create(
|
||||
{
|
||||
identityId: identityMembershipOrg.identityId,
|
||||
tenantId,
|
||||
resource,
|
||||
allowedServicePrincipalIds,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
},
|
||||
tx
|
||||
);
|
||||
await identityDAL.updateById(
|
||||
identityMembershipOrg.identityId,
|
||||
{
|
||||
authMethod: IdentityAuthMethod.AZURE_AUTH
|
||||
},
|
||||
tx
|
||||
);
|
||||
return doc;
|
||||
});
|
||||
return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const updateAzureAuth = async ({
|
||||
identityId,
|
||||
tenantId,
|
||||
resource,
|
||||
allowedServicePrincipalIds,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateAzureAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to update Azure Auth"
|
||||
});
|
||||
|
||||
const identityGcpAuth = await identityAzureAuthDAL.findOne({ identityId });
|
||||
|
||||
if (
|
||||
(accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 &&
|
||||
(accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL)
|
||||
) {
|
||||
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
accessTokenTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
const updatedAzureAuth = await identityAzureAuthDAL.updateById(identityGcpAuth.id, {
|
||||
tenantId,
|
||||
resource,
|
||||
allowedServicePrincipalIds,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
|
||||
? JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
: undefined
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedAzureAuth,
|
||||
orgId: identityMembershipOrg.orgId
|
||||
};
|
||||
};
|
||||
|
||||
const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Azure Auth attached"
|
||||
});
|
||||
|
||||
const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
return {
|
||||
login,
|
||||
attachAzureAuth,
|
||||
updateAzureAuth,
|
||||
getAzureAuth
|
||||
};
|
||||
};
|
@ -0,0 +1,120 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TLoginAzureAuthDTO = {
|
||||
identityId: string;
|
||||
jwt: string;
|
||||
};
|
||||
|
||||
export type TAttachAzureAuthDTO = {
|
||||
identityId: string;
|
||||
tenantId: string;
|
||||
resource: string;
|
||||
allowedServicePrincipalIds: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateAzureAuthDTO = {
|
||||
identityId: string;
|
||||
tenantId?: string;
|
||||
resource?: string;
|
||||
allowedServicePrincipalIds?: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetAzureAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TAzureJwksUriResponse = {
|
||||
keys: {
|
||||
kty: string;
|
||||
use: string;
|
||||
kid: string;
|
||||
x5t: string;
|
||||
n: string;
|
||||
e: string;
|
||||
x5c: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type TUserPayload = {
|
||||
aud: string;
|
||||
iss: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
acr: string;
|
||||
aio: string;
|
||||
amr: string[];
|
||||
appid: string;
|
||||
appidacr: string;
|
||||
family_name: string;
|
||||
given_name: string;
|
||||
groups: string[];
|
||||
idtyp: string;
|
||||
ipaddr: string;
|
||||
name: string;
|
||||
oid: string;
|
||||
puid: string;
|
||||
rh: string;
|
||||
scp: string;
|
||||
sub: string;
|
||||
tid: string;
|
||||
unique_name: string;
|
||||
upn: string;
|
||||
uti: string;
|
||||
ver: string;
|
||||
wids: string[];
|
||||
xms_cae: string;
|
||||
xms_cc: string[];
|
||||
xms_filter_index: string[];
|
||||
xms_rd: string;
|
||||
xms_ssm: string;
|
||||
xms_tcdt: number;
|
||||
};
|
||||
|
||||
type TAppPayload = {
|
||||
aud: string;
|
||||
iss: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
aio: string;
|
||||
appid: string;
|
||||
appidacr: string;
|
||||
idp: string;
|
||||
idtyp: string;
|
||||
oid: string; // service principal id
|
||||
rh: string;
|
||||
sub: string;
|
||||
tid: string;
|
||||
uti: string;
|
||||
ver: string;
|
||||
xms_cae: string;
|
||||
xms_cc: string[];
|
||||
xms_rd: string;
|
||||
xms_ssm: string;
|
||||
xms_tcdt: number;
|
||||
};
|
||||
|
||||
export type TAzureAuthJwtPayload = TUserPayload | TAppPayload;
|
||||
|
||||
export type TDecodedAzureAuthJwt = {
|
||||
header: {
|
||||
type: string;
|
||||
alg: string;
|
||||
x5t: string;
|
||||
kid: string;
|
||||
};
|
||||
payload: TAzureAuthJwtPayload;
|
||||
signature: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const validateAzureAuthField = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.transform((data) => {
|
||||
if (data === "") return "";
|
||||
// Trim each ID and join with ', ' to ensure formatting
|
||||
return data
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.join(", ");
|
||||
});
|
@ -259,7 +259,7 @@ export const identityProjectServiceFactory = ({
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
|
||||
const [deletedIdentity] = await identityProjectDAL.delete({ identityId });
|
||||
const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId });
|
||||
return deletedIdentity;
|
||||
};
|
||||
|
||||
|
@ -199,6 +199,7 @@ export const integrationAuthServiceFactory = ({
|
||||
projectId,
|
||||
namespace,
|
||||
integration,
|
||||
url,
|
||||
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
||||
keyEncoding: SecretKeyEncoding.UTF8,
|
||||
...(integration === Integrations.GCP_SECRET_MANAGER
|
||||
|
@ -30,7 +30,8 @@ export enum Integrations {
|
||||
DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform",
|
||||
CLOUD_66 = "cloud-66",
|
||||
NORTHFLANK = "northflank",
|
||||
HASURA_CLOUD = "hasura-cloud"
|
||||
HASURA_CLOUD = "hasura-cloud",
|
||||
RUNDECK = "rundeck"
|
||||
}
|
||||
|
||||
export enum IntegrationType {
|
||||
@ -368,6 +369,15 @@ export const getIntegrationOptions = async () => {
|
||||
type: "pat",
|
||||
clientId: "",
|
||||
docsLink: ""
|
||||
},
|
||||
{
|
||||
name: "Rundeck",
|
||||
slug: "rundeck",
|
||||
image: "Rundeck.svg",
|
||||
isAvailable: true,
|
||||
type: "pat",
|
||||
clientId: "",
|
||||
docsLink: ""
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -27,6 +27,7 @@ import { z } from "zod";
|
||||
import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types";
|
||||
|
||||
import { TIntegrationDALFactory } from "../integration/integration-dal";
|
||||
@ -521,18 +522,42 @@ const syncSecretsAWSParameterStore = async ({
|
||||
.promise();
|
||||
}
|
||||
// case: secret exists in AWS parameter store
|
||||
} else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) {
|
||||
// case: secret value doesn't match one in AWS parameter store
|
||||
} else {
|
||||
// -> update secret
|
||||
await ssm
|
||||
.putParameter({
|
||||
Name: `${integration.path}${key}`,
|
||||
Type: "SecureString",
|
||||
Value: secrets[key].value,
|
||||
Overwrite: true
|
||||
// Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : []
|
||||
})
|
||||
.promise();
|
||||
if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) {
|
||||
await ssm
|
||||
.putParameter({
|
||||
Name: `${integration.path}${key}`,
|
||||
Type: "SecureString",
|
||||
Value: secrets[key].value,
|
||||
Overwrite: true
|
||||
})
|
||||
.promise();
|
||||
}
|
||||
|
||||
if (awsParameterStoreSecretsObj[key].Name) {
|
||||
try {
|
||||
await ssm
|
||||
.addTagsToResource({
|
||||
ResourceType: "Parameter",
|
||||
ResourceId: awsParameterStoreSecretsObj[key].Name as string,
|
||||
Tags: metadata.secretAWSTag
|
||||
? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({
|
||||
Key: tag.key,
|
||||
Value: tag.value
|
||||
}))
|
||||
: []
|
||||
})
|
||||
.promise();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((err as any).code === "AccessDeniedException") {
|
||||
logger.error(
|
||||
`AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
@ -2696,18 +2721,21 @@ const syncSecretsCloudflarePages = async ({
|
||||
})
|
||||
).data.result.deployment_configs[integration.targetEnvironment as string].env_vars;
|
||||
|
||||
// copy the secrets object, so we can set deleted keys to null
|
||||
const secretsObj = Object.fromEntries(
|
||||
Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [
|
||||
key,
|
||||
key in Object.keys(getSecretsRes) ? { type: "secret_text", value: val } : null
|
||||
])
|
||||
);
|
||||
let secretEntries: [string, object | null][] = Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [
|
||||
key,
|
||||
{ type: "secret_text", value: val }
|
||||
]);
|
||||
|
||||
if (getSecretsRes) {
|
||||
const toDeleteKeys = Object.keys(getSecretsRes).filter((key) => !Object.keys(secrets).includes(key));
|
||||
const toDeleteEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]);
|
||||
secretEntries = [...secretEntries, ...toDeleteEntries];
|
||||
}
|
||||
|
||||
const data = {
|
||||
deployment_configs: {
|
||||
[integration.targetEnvironment as string]: {
|
||||
env_vars: secretsObj
|
||||
env_vars: Object.fromEntries(secretEntries)
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -3327,6 +3355,82 @@ const syncSecretsHasuraCloud = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/** Sync/push [secrets] to Rundeck
|
||||
* @param {Object} obj
|
||||
* @param {TIntegrations} obj.integration - integration details
|
||||
* @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
|
||||
* @param {String} obj.accessToken - access token for Rundeck integration
|
||||
*/
|
||||
const syncSecretsRundeck = async ({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
integration: TIntegrations;
|
||||
secrets: Record<string, { value: string; comment?: string }>;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
interface RundeckSecretResource {
|
||||
name: string;
|
||||
}
|
||||
interface RundeckSecretsGetRes {
|
||||
resources: RundeckSecretResource[];
|
||||
}
|
||||
|
||||
let existingRundeckSecrets: string[] = [];
|
||||
|
||||
try {
|
||||
const listResult = await request.get<RundeckSecretsGetRes>(
|
||||
`${integration.url}/api/44/storage/${integration.path}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Rundeck-Auth-Token": accessToken
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
existingRundeckSecrets = listResult.data.resources.map((res) => res.name);
|
||||
} catch (err) {
|
||||
logger.info("No existing rundeck secrets");
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const [key, value] of Object.entries(secrets)) {
|
||||
if (existingRundeckSecrets.includes(key)) {
|
||||
await request.put(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, {
|
||||
headers: {
|
||||
"X-Rundeck-Auth-Token": accessToken,
|
||||
"Content-Type": "application/x-rundeck-data-password"
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await request.post(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, {
|
||||
headers: {
|
||||
"X-Rundeck-Auth-Token": accessToken,
|
||||
"Content-Type": "application/x-rundeck-data-password"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for await (const existingSecret of existingRundeckSecrets) {
|
||||
if (!(existingSecret in secrets)) {
|
||||
await request.delete(`${integration.url}/api/44/storage/${integration.path}/${existingSecret}`, {
|
||||
headers: {
|
||||
"X-Rundeck-Auth-Token": accessToken
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Ensure that the provided Rundeck URL is accessible by Infisical and that the linked API token has sufficient permissions.\n\n${
|
||||
(err as Error).message
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to [app] in integration named [integration]
|
||||
*
|
||||
@ -3593,6 +3697,13 @@ export const syncIntegrationSecrets = async ({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case Integrations.RUNDECK:
|
||||
await syncSecretsRundeck({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({ message: "Invalid integration" });
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { Integrations, IntegrationUrls } from "./integration-list";
|
||||
|
||||
type Team = {
|
||||
name: string;
|
||||
teamId: string;
|
||||
id: string;
|
||||
};
|
||||
const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: string }) => {
|
||||
const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL;
|
||||
@ -22,7 +22,7 @@ const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken:
|
||||
|
||||
teams = res.map((t) => ({
|
||||
name: t.name,
|
||||
teamId: t.id
|
||||
id: t.id.toString()
|
||||
}));
|
||||
|
||||
return teams;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
@ -43,6 +43,7 @@ export const integrationServiceFactory = ({
|
||||
scope,
|
||||
actorId,
|
||||
region,
|
||||
url,
|
||||
isActive,
|
||||
metadata,
|
||||
secretPath,
|
||||
@ -66,6 +67,11 @@ export const integrationServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder path not found" });
|
||||
|
||||
@ -82,6 +88,7 @@ export const integrationServiceFactory = ({
|
||||
region,
|
||||
scope,
|
||||
owner,
|
||||
url,
|
||||
appId,
|
||||
path,
|
||||
app,
|
||||
@ -123,6 +130,11 @@ export const integrationServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder path not found" });
|
||||
|
||||
|
@ -12,6 +12,7 @@ export type TCreateIntegrationDTO = {
|
||||
targetService?: string;
|
||||
targetServiceId?: string;
|
||||
owner?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
region?: string;
|
||||
scope?: string;
|
||||
|
10
backend/src/services/kms/kms-dal.ts
Normal file
10
backend/src/services/kms/kms-dal.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TKmsDALFactory = ReturnType<typeof kmsDALFactory>;
|
||||
|
||||
export const kmsDALFactory = (db: TDbClient) => {
|
||||
const kmsOrm = ormify(db, TableName.KmsKey);
|
||||
return kmsOrm;
|
||||
};
|
10
backend/src/services/kms/kms-root-config-dal.ts
Normal file
10
backend/src/services/kms/kms-root-config-dal.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TKmsRootConfigDALFactory = ReturnType<typeof kmsRootConfigDALFactory>;
|
||||
|
||||
export const kmsRootConfigDALFactory = (db: TDbClient) => {
|
||||
const kmsOrm = ormify(db, TableName.KmsServerRootConfig);
|
||||
return kmsOrm;
|
||||
};
|
126
backend/src/services/kms/kms-service.ts
Normal file
126
backend/src/services/kms/kms-service.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { randomSecureBytes } from "@app/lib/crypto";
|
||||
import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TKmsDALFactory } from "./kms-dal";
|
||||
import { TKmsRootConfigDALFactory } from "./kms-root-config-dal";
|
||||
import { TDecryptWithKmsDTO, TEncryptWithKmsDTO, TGenerateKMSDTO } from "./kms-types";
|
||||
|
||||
type TKmsServiceFactoryDep = {
|
||||
kmsDAL: TKmsDALFactory;
|
||||
kmsRootConfigDAL: Pick<TKmsRootConfigDALFactory, "findById" | "create">;
|
||||
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "waitTillReady" | "setItemWithExpiry">;
|
||||
};
|
||||
|
||||
export type TKmsServiceFactory = ReturnType<typeof kmsServiceFactory>;
|
||||
|
||||
const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key";
|
||||
const KMS_ROOT_CREATION_WAIT_TIME = 10;
|
||||
|
||||
// akhilmhdh: Don't edit this value. This is measured for blob concatination in kms
|
||||
const KMS_VERSION = "v01";
|
||||
const KMS_VERSION_BLOB_LENGTH = 3;
|
||||
export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsServiceFactoryDep) => {
|
||||
let ROOT_ENCRYPTION_KEY = Buffer.alloc(0);
|
||||
|
||||
// this is used symmetric encryption
|
||||
const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => {
|
||||
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
|
||||
const kmsKeyMaterial = randomSecureBytes(32);
|
||||
const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY);
|
||||
|
||||
const { encryptedKey, ...doc } = await kmsDAL.create({
|
||||
version: 1,
|
||||
encryptedKey: encryptedKeyMaterial,
|
||||
encryptionAlgorithm: SymmetricEncryption.AES_GCM_256,
|
||||
isReserved,
|
||||
orgId: scopeType === "org" ? scopeId : undefined,
|
||||
projectId: scopeType === "project" ? scopeId : undefined
|
||||
});
|
||||
return doc;
|
||||
};
|
||||
|
||||
const encrypt = async ({ kmsId, plainText }: TEncryptWithKmsDTO) => {
|
||||
const kmsDoc = await kmsDAL.findById(kmsId);
|
||||
if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" });
|
||||
// akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm
|
||||
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
|
||||
|
||||
const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY);
|
||||
const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey);
|
||||
|
||||
// Buffer#1 encrypted text + Buffer#2 version number
|
||||
const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3
|
||||
const cipherTextBlob = Buffer.concat([encryptedPlainTextBlob, versionBlob]);
|
||||
return { cipherTextBlob };
|
||||
};
|
||||
|
||||
const decrypt = async ({ cipherTextBlob: versionedCipherTextBlob, kmsId }: TDecryptWithKmsDTO) => {
|
||||
const kmsDoc = await kmsDAL.findById(kmsId);
|
||||
if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" });
|
||||
// akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm
|
||||
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
|
||||
const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY);
|
||||
|
||||
const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH);
|
||||
const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey);
|
||||
return decryptedBlob;
|
||||
};
|
||||
|
||||
const startService = async () => {
|
||||
const appCfg = getConfig();
|
||||
// This will switch to a seal process and HMS flow in future
|
||||
const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY;
|
||||
// if root key its base64 encoded
|
||||
const isBase64 = Boolean(appCfg.ROOT_ENCRYPTION_KEY);
|
||||
if (!encryptionKey) throw new Error("Root encryption key not found for KMS service.");
|
||||
const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8");
|
||||
|
||||
const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null);
|
||||
if (!lock) {
|
||||
await keyStore.waitTillReady({
|
||||
key: KMS_ROOT_CREATION_WAIT_KEY,
|
||||
keyCheckCb: (val) => val === "true",
|
||||
waitingCb: () => logger.info("KMS. Waiting for leader to finish creation of KMS Root Key")
|
||||
});
|
||||
}
|
||||
|
||||
// check if KMS root key was already generated and saved in DB
|
||||
const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID);
|
||||
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
|
||||
if (kmsRootConfig) {
|
||||
if (lock) await lock.release();
|
||||
logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting.");
|
||||
const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer);
|
||||
// set the flag so that other instancen nodes can start
|
||||
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
|
||||
logger.info("KMS: Loading ROOT Key into Memory.");
|
||||
ROOT_ENCRYPTION_KEY = decryptedRootKey;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("KMS: Generating ROOT Key");
|
||||
const newRootKey = randomSecureBytes(32);
|
||||
const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer);
|
||||
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
|
||||
await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID });
|
||||
|
||||
// set the flag so that other instancen nodes can start
|
||||
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
|
||||
logger.info("KMS: Saved and loaded ROOT Key into memory");
|
||||
if (lock) await lock.release();
|
||||
ROOT_ENCRYPTION_KEY = newRootKey;
|
||||
};
|
||||
|
||||
return {
|
||||
startService,
|
||||
generateKmsKey,
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
};
|
15
backend/src/services/kms/kms-types.ts
Normal file
15
backend/src/services/kms/kms-types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type TGenerateKMSDTO = {
|
||||
scopeType: "project" | "org";
|
||||
scopeId: string;
|
||||
isReserved?: boolean;
|
||||
};
|
||||
|
||||
export type TEncryptWithKmsDTO = {
|
||||
kmsId: string;
|
||||
plainText: Buffer;
|
||||
};
|
||||
|
||||
export type TDecryptWithKmsDTO = {
|
||||
kmsId: string;
|
||||
cipherTextBlob: Buffer;
|
||||
};
|
@ -1,25 +1,30 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
|
||||
import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas";
|
||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
projectAdminPermissions,
|
||||
projectMemberPermissions,
|
||||
projectNoAccessPermissions,
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub,
|
||||
projectViewerPermission
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { ActorAuthMethod } from "../auth/auth-type";
|
||||
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TProjectRoleDALFactory } from "./project-role-dal";
|
||||
import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types";
|
||||
|
||||
type TProjectRoleServiceFactoryDep = {
|
||||
projectRoleDAL: TProjectRoleDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
|
||||
identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory;
|
||||
projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory;
|
||||
@ -27,20 +32,68 @@ type TProjectRoleServiceFactoryDep = {
|
||||
|
||||
export type TProjectRoleServiceFactory = ReturnType<typeof projectRoleServiceFactory>;
|
||||
|
||||
const unpackPermissions = (permissions: unknown) =>
|
||||
UnpackedPermissionSchema.array().parse(
|
||||
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
|
||||
);
|
||||
|
||||
const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => {
|
||||
return [
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
|
||||
projectId,
|
||||
name: "Admin",
|
||||
slug: ProjectMembershipRole.Admin,
|
||||
permissions: projectAdminPermissions,
|
||||
description: "Full administrative access over a project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "Developer",
|
||||
slug: ProjectMembershipRole.Member,
|
||||
permissions: projectMemberPermissions,
|
||||
description: "Limited read/write role in a project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "Viewer",
|
||||
slug: ProjectMembershipRole.Viewer,
|
||||
permissions: projectViewerPermission,
|
||||
description: "Only read role in a project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "No Access",
|
||||
slug: ProjectMembershipRole.NoAccess,
|
||||
permissions: projectNoAccessPermissions,
|
||||
description: "No access to any resources in the project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
].filter(({ slug }) => !roleFilter || roleFilter.includes(slug));
|
||||
};
|
||||
|
||||
export const projectRoleServiceFactory = ({
|
||||
projectRoleDAL,
|
||||
permissionService,
|
||||
identityProjectMembershipRoleDAL,
|
||||
projectUserMembershipRoleDAL
|
||||
projectUserMembershipRoleDAL,
|
||||
projectDAL
|
||||
}: TProjectRoleServiceFactoryDep) => {
|
||||
const createRole = async (
|
||||
actor: ActorType,
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
data: Omit<TProjectRolesInsert, "projectId">,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const createRole = async ({ projectSlug, data, actor, actorId, actorAuthMethod, actorOrgId }: TCreateRoleDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@ -53,21 +106,54 @@ export const projectRoleServiceFactory = ({
|
||||
if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" });
|
||||
const role = await projectRoleDAL.create({
|
||||
...data,
|
||||
projectId,
|
||||
permissions: JSON.stringify(data.permissions)
|
||||
projectId
|
||||
});
|
||||
return role;
|
||||
return { ...role, permissions: unpackPermissions(role.permissions) };
|
||||
};
|
||||
|
||||
const updateRole = async (
|
||||
actor: ActorType,
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
roleId: string,
|
||||
data: Omit<TOrgRolesUpdate, "orgId">,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const getRoleBySlug = async ({
|
||||
actor,
|
||||
actorId,
|
||||
projectSlug,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
roleSlug
|
||||
}: TGetRoleBySlugDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
|
||||
if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) {
|
||||
const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0];
|
||||
return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) };
|
||||
}
|
||||
|
||||
const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId });
|
||||
if (!customRole) throw new BadRequestError({ message: "Role not found" });
|
||||
return { ...customRole, permissions: unpackPermissions(customRole.permissions) };
|
||||
};
|
||||
|
||||
const updateRole = async ({
|
||||
roleId,
|
||||
projectSlug,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorId,
|
||||
actor,
|
||||
data
|
||||
}: TUpdateRoleDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@ -81,22 +167,16 @@ export const projectRoleServiceFactory = ({
|
||||
if (existingRole && existingRole.id !== roleId)
|
||||
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
|
||||
}
|
||||
const [updatedRole] = await projectRoleDAL.update(
|
||||
{ id: roleId, projectId },
|
||||
{ ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined }
|
||||
);
|
||||
const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data);
|
||||
if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
||||
return updatedRole;
|
||||
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
|
||||
};
|
||||
|
||||
const deleteRole = async (
|
||||
actor: ActorType,
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
roleId: string,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, projectSlug, roleId }: TDeleteRoleDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@ -125,16 +205,14 @@ export const projectRoleServiceFactory = ({
|
||||
const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId });
|
||||
if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" });
|
||||
|
||||
return deletedRole;
|
||||
return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) };
|
||||
};
|
||||
|
||||
const listRoles = async (
|
||||
actor: ActorType,
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const listRoles = async ({ projectSlug, actorOrgId, actorAuthMethod, actorId, actor }: TListRolesDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
const projectId = project.id;
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@ -144,52 +222,7 @@ export const projectRoleServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
|
||||
const customRoles = await projectRoleDAL.find({ projectId });
|
||||
const roles = [
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
|
||||
projectId,
|
||||
name: "Admin",
|
||||
slug: ProjectMembershipRole.Admin,
|
||||
description: "Complete administration access over the project",
|
||||
permissions: packRules(projectAdminPermissions),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "Developer",
|
||||
slug: ProjectMembershipRole.Member,
|
||||
description: "Non-administrative role in an project",
|
||||
permissions: packRules(projectMemberPermissions),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "Viewer",
|
||||
slug: ProjectMembershipRole.Viewer,
|
||||
description: "Non-administrative role in an project",
|
||||
permissions: packRules(projectViewerPermission),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
|
||||
projectId,
|
||||
name: "No Access",
|
||||
slug: "no-access",
|
||||
description: "No access to any resources in the project",
|
||||
permissions: packRules(projectNoAccessPermissions),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
...(customRoles || []).map(({ permissions, ...data }) => ({
|
||||
...data,
|
||||
permissions
|
||||
}))
|
||||
];
|
||||
const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])];
|
||||
|
||||
return roles;
|
||||
};
|
||||
@ -209,5 +242,5 @@ export const projectRoleServiceFactory = ({
|
||||
return { permissions: packRules(permission.rules), membership };
|
||||
};
|
||||
|
||||
return { createRole, updateRole, deleteRole, listRoles, getUserPermission };
|
||||
return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug };
|
||||
};
|
||||
|
@ -0,0 +1,27 @@
|
||||
import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TCreateRoleDTO = {
|
||||
data: Omit<TProjectRolesInsert, "projectId">;
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetRoleBySlugDTO = {
|
||||
roleSlug: string;
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateRoleDTO = {
|
||||
roleId: string;
|
||||
data: Omit<TOrgRolesUpdate, "orgId">;
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteRoleDTO = {
|
||||
roleId: string;
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TListRolesDTO = {
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
@ -0,0 +1,62 @@
|
||||
import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
|
||||
|
||||
type TDailyResourceCleanUpQueueServiceFactoryDep = {
|
||||
auditLogDAL: Pick<TAuditLogDALFactory, "pruneAuditLog">;
|
||||
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "removeExpiredTokens">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets">;
|
||||
queueService: TQueueServiceFactory;
|
||||
};
|
||||
|
||||
export type TDailyResourceCleanUpQueueServiceFactory = ReturnType<typeof dailyResourceCleanUpQueueServiceFactory>;
|
||||
|
||||
export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL
|
||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||
await auditLogDAL.pruneAuditLog();
|
||||
await identityAccessTokenDAL.removeExpiredTokens();
|
||||
await secretSharingDAL.pruneExpiredSharedSecrets();
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
||||
});
|
||||
|
||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
||||
const startCleanUp = async () => {
|
||||
// TODO(akhilmhdh): remove later
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.AuditLogPrune,
|
||||
QueueJobs.AuditLogPrune,
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.AuditLogPrune // just a job id
|
||||
);
|
||||
// clear previous job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.DailyResourceCleanUp,
|
||||
QueueJobs.DailyResourceCleanUp,
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.DailyResourceCleanUp // just a job id
|
||||
);
|
||||
|
||||
await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, {
|
||||
delay: 5000,
|
||||
jobId: QueueName.DailyResourceCleanUp,
|
||||
repeat: { pattern: "0 0 * * *", utc: true }
|
||||
});
|
||||
};
|
||||
|
||||
queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => {
|
||||
logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
|
||||
});
|
||||
|
||||
return {
|
||||
startCleanUp
|
||||
};
|
||||
};
|
@ -169,6 +169,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
|
||||
// this is for root condition
|
||||
// if the given folder id is root folder id then intial path is set as / instead of /root
|
||||
// if not root folder the path here will be /<folder name>
|
||||
depth: 1,
|
||||
path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`),
|
||||
child: db.raw("NULL::uuid"),
|
||||
environmentSlug: `${TableName.Environment}.slug`
|
||||
@ -185,6 +186,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
|
||||
.select({
|
||||
// then we join join this folder name behind previous as we are going from child to parent
|
||||
// the root folder check is used to avoid last / and also root name in folders
|
||||
depth: db.raw("parent.depth + 1"),
|
||||
path: db.raw(
|
||||
`CONCAT( CASE
|
||||
WHEN ${TableName.SecretFolder}."parentId" is NULL THEN ''
|
||||
@ -199,7 +201,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
|
||||
);
|
||||
})
|
||||
.select("*")
|
||||
.from<TSecretFolders & { child: string | null; path: string; environmentSlug: string }>("parent");
|
||||
.from<TSecretFolders & { child: string | null; path: string; environmentSlug: string; depth: number }>("parent");
|
||||
|
||||
export type TSecretFolderDALFactory = ReturnType<typeof secretFolderDALFactory>;
|
||||
// never change this. If u do write a migration for it
|
||||
@ -260,12 +262,23 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds);
|
||||
|
||||
// travelling all the way from leaf node to root contains real path
|
||||
const rootFolders = groupBy(
|
||||
folders.filter(({ parentId }) => parentId === null),
|
||||
(i) => i.child || i.id // root condition then child and parent will null
|
||||
);
|
||||
const actualFolders = groupBy(
|
||||
folders.filter(({ depth }) => depth === 1),
|
||||
(i) => i.id // root condition then child and parent will null
|
||||
);
|
||||
|
||||
return folderIds.map((folderId) => rootFolders[folderId]?.[0]);
|
||||
return folderIds.map((folderId) => {
|
||||
if (!rootFolders[folderId]?.[0]) return;
|
||||
|
||||
const actualId = rootFolders[folderId][0].child || rootFolders[folderId][0].id;
|
||||
const folder = actualFolders[actualId][0];
|
||||
return { ...folder, path: rootFolders[folderId]?.[0].path };
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find by secret path" });
|
||||
}
|
||||
|
@ -253,7 +253,7 @@ export const secretFolderServiceFactory = ({
|
||||
const env = await projectEnvDAL.findOne({ projectId, slug: environment });
|
||||
if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" });
|
||||
const folder = await folderDAL
|
||||
.findOne({ envId: env.id, id, parentId: parentFolder.id })
|
||||
.findOne({ envId: env.id, id, parentId: parentFolder.id, isReserved: false })
|
||||
// now folder api accepts id based change
|
||||
// this is for cli backward compatiability and when cli removes this, we will remove this logic
|
||||
.catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id }));
|
||||
@ -276,7 +276,11 @@ export const secretFolderServiceFactory = ({
|
||||
}
|
||||
|
||||
const newFolder = await folderDAL.transaction(async (tx) => {
|
||||
const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx);
|
||||
const [doc] = await folderDAL.update(
|
||||
{ envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false },
|
||||
{ name },
|
||||
tx
|
||||
);
|
||||
await folderVersionDAL.create(
|
||||
{
|
||||
name: doc.name,
|
||||
@ -324,7 +328,12 @@ export const secretFolderServiceFactory = ({
|
||||
if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" });
|
||||
|
||||
const [doc] = await folderDAL.delete(
|
||||
{ envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id },
|
||||
{
|
||||
envId: env.id,
|
||||
[uuidValidate(idOrName) ? "id" : "name"]: idOrName,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" });
|
||||
@ -354,7 +363,7 @@ export const secretFolderServiceFactory = ({
|
||||
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!parentFolder) return [];
|
||||
|
||||
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id });
|
||||
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false });
|
||||
|
||||
return folders;
|
||||
};
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum ReservedFolders {
|
||||
SecretReplication = "__reserve_replication_"
|
||||
}
|
||||
|
||||
export type TCreateFolderDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
|
@ -15,7 +15,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const docs = await (tx || db)(TableName.SecretFolderVersion)
|
||||
.join(TableName.SecretFolder, `${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`)
|
||||
.where({ parentId: folderId })
|
||||
.where({ parentId: folderId, isReserved: false })
|
||||
.join<TSecretFolderVersions>(
|
||||
(tx || db)(TableName.SecretFolderVersion)
|
||||
.groupBy("envId", "folderId")
|
||||
|
@ -20,14 +20,14 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
return lastPos?.position || 0;
|
||||
};
|
||||
|
||||
const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => {
|
||||
const updateAllPosition = async (folderId: string, pos: number, targetPos: number, positionInc = 1, tx?: Knex) => {
|
||||
try {
|
||||
if (targetPos === -1) {
|
||||
// this means delete
|
||||
await (tx || db)(TableName.SecretImport)
|
||||
.where({ folderId })
|
||||
.andWhere("position", ">", pos)
|
||||
.decrement("position", 1);
|
||||
.decrement("position", positionInc);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -36,13 +36,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
.where({ folderId })
|
||||
.where("position", "<=", targetPos)
|
||||
.andWhere("position", ">", pos)
|
||||
.decrement("position", 1);
|
||||
.decrement("position", positionInc);
|
||||
} else {
|
||||
await (tx || db)(TableName.SecretImport)
|
||||
.where({ folderId })
|
||||
.where("position", ">=", targetPos)
|
||||
.andWhere("position", "<", pos)
|
||||
.increment("position", 1);
|
||||
.increment("position", positionInc);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update position" });
|
||||
@ -74,6 +74,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const docs = await (tx || db)(TableName.SecretImport)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where("isReplication", false)
|
||||
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
|
||||
.select(
|
||||
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
|
||||
|
@ -79,7 +79,7 @@ export const fnSecretsFromImports = async ({
|
||||
let secretsFromDeeperImports: TSecretImportSecrets[] = [];
|
||||
if (deeperImports.length) {
|
||||
secretsFromDeeperImports = await fnSecretsFromImports({
|
||||
allowedImports: deeperImports,
|
||||
allowedImports: deeperImports.filter(({ isReplication }) => !isReplication),
|
||||
secretImportDAL,
|
||||
folderDAL,
|
||||
secretDAL,
|
||||
|
@ -1,7 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
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 { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
@ -16,6 +21,7 @@ import {
|
||||
TDeleteSecretImportDTO,
|
||||
TGetSecretImportsDTO,
|
||||
TGetSecretsFromImportDTO,
|
||||
TResyncSecretImportReplicationDTO,
|
||||
TUpdateSecretImportDTO
|
||||
} from "./secret-import-types";
|
||||
|
||||
@ -26,7 +32,8 @@ type TSecretImportServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||
projectEnvDAL: TProjectEnvDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "replicateSecrets">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" });
|
||||
@ -40,7 +47,8 @@ export const secretImportServiceFactory = ({
|
||||
folderDAL,
|
||||
projectDAL,
|
||||
secretDAL,
|
||||
secretQueueService
|
||||
secretQueueService,
|
||||
licenseService
|
||||
}: TSecretImportServiceFactoryDep) => {
|
||||
const createImport = async ({
|
||||
environment,
|
||||
@ -50,7 +58,8 @@ export const secretImportServiceFactory = ({
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
path
|
||||
isReplication,
|
||||
path: secretPath
|
||||
}: TCreateSecretImportDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@ -63,7 +72,7 @@ export const secretImportServiceFactory = ({
|
||||
// check if user has permission to import into destination path
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
// check if user has permission to import from target path
|
||||
@ -74,10 +83,18 @@ export const secretImportServiceFactory = ({
|
||||
secretPath: data.path
|
||||
})
|
||||
);
|
||||
if (isReplication) {
|
||||
const plan = await licenseService.getPlan(actorOrgId);
|
||||
if (!plan.secretApproval) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
|
||||
|
||||
const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]);
|
||||
@ -88,35 +105,62 @@ export const secretImportServiceFactory = ({
|
||||
const existingImport = await secretImportDAL.findOne({
|
||||
folderId: sourceFolder.id,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: path
|
||||
importPath: secretPath
|
||||
});
|
||||
if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" });
|
||||
}
|
||||
|
||||
const secImport = await secretImportDAL.transaction(async (tx) => {
|
||||
const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx);
|
||||
return secretImportDAL.create(
|
||||
const doc = await secretImportDAL.create(
|
||||
{
|
||||
folderId: folder.id,
|
||||
position: lastPos + 1,
|
||||
importEnv: importEnv.id,
|
||||
importPath: data.path
|
||||
importPath: data.path,
|
||||
isReplication
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (doc.isReplication) {
|
||||
await secretImportDAL.create(
|
||||
{
|
||||
folderId: folder.id,
|
||||
position: lastPos + 2,
|
||||
isReserved: true,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: path.join(secretPath, getReplicationFolderName(doc.id))
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
|
||||
await secretQueueService.syncSecrets({
|
||||
secretPath: secImport.importPath,
|
||||
projectId,
|
||||
environment: importEnv.slug
|
||||
});
|
||||
if (secImport.isReplication && sourceFolder) {
|
||||
await secretQueueService.replicateSecrets({
|
||||
secretPath: secImport.importPath,
|
||||
projectId,
|
||||
environmentSlug: importEnv.slug,
|
||||
pickOnlyImportIds: [secImport.id],
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
} else {
|
||||
await secretQueueService.syncSecrets({
|
||||
secretPath,
|
||||
projectId,
|
||||
environmentSlug: environment,
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
}
|
||||
|
||||
return { ...secImport, importEnv };
|
||||
};
|
||||
|
||||
const updateImport = async ({
|
||||
path,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
@ -135,10 +179,10 @@ export const secretImportServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
|
||||
|
||||
const secImpDoc = await secretImportDAL.findOne({ folderId: folder.id, id });
|
||||
@ -158,7 +202,7 @@ export const secretImportServiceFactory = ({
|
||||
const existingImport = await secretImportDAL.findOne({
|
||||
folderId: sourceFolder.id,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: path
|
||||
importPath: secretPath
|
||||
});
|
||||
if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" });
|
||||
}
|
||||
@ -167,12 +211,31 @@ export const secretImportServiceFactory = ({
|
||||
const secImp = await secretImportDAL.findOne({ folderId: folder.id, id });
|
||||
if (!secImp) throw ERR_SEC_IMP_NOT_FOUND;
|
||||
if (data.position) {
|
||||
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, tx);
|
||||
if (secImp.isReplication) {
|
||||
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 2, tx);
|
||||
} else {
|
||||
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 1, tx);
|
||||
}
|
||||
}
|
||||
if (secImp.isReplication) {
|
||||
const replicationFolderPath = path.join(secretPath, getReplicationFolderName(secImp.id));
|
||||
await secretImportDAL.update(
|
||||
{
|
||||
folderId: folder.id,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: replicationFolderPath,
|
||||
isReserved: true
|
||||
},
|
||||
{ position: data?.position ? data.position + 1 : undefined },
|
||||
tx
|
||||
);
|
||||
}
|
||||
const [doc] = await secretImportDAL.update(
|
||||
{ id, folderId: folder.id },
|
||||
{
|
||||
position: data?.position,
|
||||
// when moving replicated import, the position is meant for reserved import
|
||||
// replicated one should always be behind the reserved import
|
||||
position: data.position,
|
||||
importEnv: data?.environment ? importedEnv.id : undefined,
|
||||
importPath: data?.path
|
||||
},
|
||||
@ -184,7 +247,7 @@ export const secretImportServiceFactory = ({
|
||||
};
|
||||
|
||||
const deleteImport = async ({
|
||||
path,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
@ -202,16 +265,34 @@ export const secretImportServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" });
|
||||
|
||||
const secImport = await secretImportDAL.transaction(async (tx) => {
|
||||
const [doc] = await secretImportDAL.delete({ folderId: folder.id, id }, tx);
|
||||
if (!doc) throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" });
|
||||
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, tx);
|
||||
if (doc.isReplication) {
|
||||
const replicationFolderPath = path.join(secretPath, getReplicationFolderName(doc.id));
|
||||
const replicatedFolder = await folderDAL.findBySecretPath(projectId, environment, replicationFolderPath, tx);
|
||||
if (replicatedFolder) {
|
||||
await secretImportDAL.delete(
|
||||
{
|
||||
folderId: folder.id,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: replicationFolderPath,
|
||||
isReserved: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
await folderDAL.deleteById(replicatedFolder.id, tx);
|
||||
}
|
||||
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 2, tx);
|
||||
} else {
|
||||
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 1, tx);
|
||||
}
|
||||
|
||||
const importEnv = await projectEnvDAL.findById(doc.importEnv);
|
||||
if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" });
|
||||
@ -219,16 +300,91 @@ export const secretImportServiceFactory = ({
|
||||
});
|
||||
|
||||
await secretQueueService.syncSecrets({
|
||||
secretPath: path,
|
||||
secretPath,
|
||||
projectId,
|
||||
environment
|
||||
environmentSlug: environment,
|
||||
actor,
|
||||
actorId
|
||||
});
|
||||
|
||||
return secImport;
|
||||
};
|
||||
|
||||
const resyncSecretImportReplication = async ({
|
||||
environment,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
id: secretImportDocId
|
||||
}: TResyncSecretImportReplicationDTO) => {
|
||||
const { permission, membership } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
// check if user has permission to import into destination path
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const plan = await licenseService.getPlan(actorOrgId);
|
||||
if (!plan.secretApproval) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication."
|
||||
});
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
|
||||
|
||||
const [secretImportDoc] = await secretImportDAL.find({
|
||||
folderId: folder.id,
|
||||
[`${TableName.SecretImport}.id` as "id"]: secretImportDocId
|
||||
});
|
||||
if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" });
|
||||
|
||||
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(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: secretImportDoc.importEnv.slug,
|
||||
secretPath: secretImportDoc.importPath
|
||||
})
|
||||
);
|
||||
|
||||
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||
|
||||
const sourceFolder = await folderDAL.findBySecretPath(
|
||||
projectId,
|
||||
secretImportDoc.importEnv.slug,
|
||||
secretImportDoc.importPath
|
||||
);
|
||||
|
||||
if (membership && sourceFolder) {
|
||||
await secretQueueService.replicateSecrets({
|
||||
secretPath: secretImportDoc.importPath,
|
||||
projectId,
|
||||
environmentSlug: secretImportDoc.importEnv.slug,
|
||||
pickOnlyImportIds: [secretImportDoc.id],
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
}
|
||||
|
||||
return { message: "replication started" };
|
||||
};
|
||||
|
||||
const getImports = async ({
|
||||
path,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
@ -245,10 +401,10 @@ export const secretImportServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
|
||||
|
||||
const secImports = await secretImportDAL.find({ folderId: folder.id });
|
||||
@ -256,7 +412,7 @@ export const secretImportServiceFactory = ({
|
||||
};
|
||||
|
||||
const getSecretsFromImports = async ({
|
||||
path,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
@ -273,13 +429,13 @@ export const secretImportServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return [];
|
||||
// this will already order by position
|
||||
// so anything based on this order will also be in right position
|
||||
const secretImports = await secretImportDAL.find({ folderId: folder.id });
|
||||
const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false });
|
||||
|
||||
const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
|
||||
permission.can(
|
||||
@ -299,6 +455,7 @@ export const secretImportServiceFactory = ({
|
||||
deleteImport,
|
||||
getImports,
|
||||
getSecretsFromImports,
|
||||
resyncSecretImportReplication,
|
||||
fnSecretsFromImports
|
||||
};
|
||||
};
|
||||
|
@ -7,6 +7,7 @@ export type TCreateSecretImportDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
};
|
||||
isReplication?: boolean;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateSecretImportDTO = {
|
||||
@ -16,6 +17,12 @@ export type TUpdateSecretImportDTO = {
|
||||
data: Partial<{ environment: string; path: string; position: number }>;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TResyncSecretImportReplicationDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
id: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TDeleteSecretImportDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user