From 7a61995dd4f4d4e611b9c7fc348efe981baa2309 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 22 Nov 2024 00:04:41 +0530 Subject: [PATCH 01/70] feat: added template support in operator --- .../api/v1alpha1/infisicalsecret_types.go | 14 +++ ...ecrets.infisical.com_infisicalsecrets.yaml | 14 +++ .../config/samples/sample-with-template.yml | 113 ++++++++++++++++++ .../controllers/infisicalsecret_helper.go | 74 +++++++++++- k8-operator/main.go | 2 +- k8-operator/packages/model/model.go | 9 +- k8-operator/packages/util/secrets.go | 27 +++-- 7 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 k8-operator/config/samples/sample-with-template.yml diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index 65da2498c..1af2faf20 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -147,6 +147,20 @@ type MangedKubeSecretConfig struct { // +kubebuilder:validation:Optional // +kubebuilder:default:=Orphan CreationPolicy string `json:"creationPolicy"` + + // The template to transform the secret data + // +kubebuilder:validation:Optional + Template *InfisicalSecretTemplate `json:"template,omitempty"` +} + +type InfisicalSecretTemplate struct { + // This injects all retrieved secrets into the top level of your template. + // Secrets defined in the template will take precedence over the injected ones. + // +kubebuilder:validation:Optional + IncludeAllSecrets bool `json:"includeAllSecrets"` + // The template key values + // +kubebuilder:validation:Optional + Data map[string]string `json:"data,omitempty"` } type CaReference struct { diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 633b48460..78027f929 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -283,6 +283,20 @@ spec: description: 'The Kubernetes Secret type (experimental feature). More info: https://kubernetes.io/docs/concepts/configuration/secret/#secret-types' type: string + template: + description: The template to transform the secret data + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the top + level of your template. Secrets defined in the template + will take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/k8-operator/config/samples/sample-with-template.yml b/k8-operator/config/samples/sample-with-template.yml new file mode 100644 index 000000000..9d9d86ab5 --- /dev/null +++ b/k8-operator/config/samples/sample-with-template.yml @@ -0,0 +1,113 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" +spec: + hostAPI: https://app.infisical.com/api + resyncInterval: 10 + # tls: + # caRef: + # secretName: custom-ca-certificate + # secretNamespace: default + # key: ca.crt + authentication: + # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. + # If you have multiple authentication methods defined, it may cause issues. + + # (Deprecated) Service Token Auth + serviceToken: + serviceTokenSecretReference: + secretName: service-token + secretNamespace: default + secretsScope: + envSlug: + secretsPath: + recursive: true + + # Universal Auth + universalAuth: + secretsScope: + projectSlug: new-ob-em + envSlug: dev # "dev", "staging", "prod", etc.. + secretsPath: "/" # Root is "/" + recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false + credentialsRef: + secretName: universal-auth-credentials + secretNamespace: default + + # Native Kubernetes Auth + kubernetesAuth: + identityId: + serviceAccountTokenPath: "/path/to/your/service-account/token" # Optional, defaults to /var/run/secrets/kubernetes.io/serviceaccount/token + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # AWS IAM Auth + awsIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # Azure Auth + azureAuth: + identityId: + resource: https://management.azure.com/&client_id=your_client_id # This field is optional, and will default to "https://management.azure.com/" if nothing is provided. + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP ID Token Auth + gcpIdTokenAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP IAM Auth + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: "/path/to-service-account-key-file-path.json" + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + managedSecretReference: + secretName: managed-secret + secretNamespace: default + template: + includeAllSecrets: true + data: + SSH_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" + creationPolicy: "Orphan" ## Owner | Orphan + # secretType: kubernetes.io/dockerconfigjson + + # # To be depreciated soon + # tokenSecretReference: + # secretName: service-token + # secretNamespace: default diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index a66b4d799..042d19edf 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -1,10 +1,12 @@ package controllers import ( + "bytes" "context" "errors" "fmt" "strings" + "text/template" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" @@ -225,12 +227,44 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub return model.ServiceAccountDetails{AccessKey: string(accessKeyFromSecret), PrivateKey: string(privateKeyFromSecret), PublicKey: string(publicKeyFromSecret)}, nil } +type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` +} + func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { plainProcessedSecrets := make(map[string][]byte) secretType := infisicalSecret.Spec.ManagedSecretReference.SecretType + managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template - for _, secret := range secretsFromAPI { - plainProcessedSecrets[secret.Key] = []byte(secret.Value) // plain process + if managedTemplateData == nil || managedTemplateData.IncludeAllSecrets { + for _, secret := range secretsFromAPI { + plainProcessedSecrets[secret.Key] = []byte(secret.Value) // plain process + } + } + + if managedTemplateData != nil { + secretKeyValue := make(map[string]TemplateSecret) + for _, secret := range secretsFromAPI { + secretKeyValue[secret.Key] = TemplateSecret{ + Value: secret.Value, + SecretPath: secret.SecretPath, + } + } + + for tmplKey, userTmpl := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTmpl) + if err != nil { + return fmt.Errorf("Unable to compile template: %s", tmplKey, err) + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, secretKeyValue) + if err != nil { + return fmt.Errorf("Unable to execute template: %s", tmplKey, err) + } + plainProcessedSecrets[tmplKey] = buf.Bytes() + } } // copy labels and annotations from InfisicalSecret CRD @@ -285,10 +319,38 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context return nil } -func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context.Context, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { +func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { + managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template + plainProcessedSecrets := make(map[string][]byte) - for _, secret := range secretsFromAPI { - plainProcessedSecrets[secret.Key] = []byte(secret.Value) + if managedTemplateData == nil || managedTemplateData.IncludeAllSecrets { + for _, secret := range secretsFromAPI { + plainProcessedSecrets[secret.Key] = []byte(secret.Value) + } + } + + if managedTemplateData != nil { + secretKeyValue := make(map[string]TemplateSecret) + for _, secret := range secretsFromAPI { + secretKeyValue[secret.Key] = TemplateSecret{ + Value: secret.Value, + SecretPath: secret.SecretPath, + } + } + + for tmplKey, userTmpl := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTmpl) + if err != nil { + return fmt.Errorf("Unable to compile template: %s", tmplKey, err) + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, secretKeyValue) + if err != nil { + return fmt.Errorf("Unable to execute template: %s", tmplKey, err) + } + plainProcessedSecrets[tmplKey] = buf.Bytes() + } } // Initialize the Annotations map if it's nil @@ -434,7 +496,7 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context if managedKubeSecret == nil { return r.CreateInfisicalManagedKubeSecret(ctx, infisicalSecret, plainTextSecretsFromApi, updateDetails.ETag) } else { - return r.UpdateInfisicalManagedKubeSecret(ctx, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) + return r.UpdateInfisicalManagedKubeSecret(ctx, infisicalSecret, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) } } diff --git a/k8-operator/main.go b/k8-operator/main.go index 50c0cda00..d400545ff 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -36,7 +36,7 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8082", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ diff --git a/k8-operator/packages/model/model.go b/k8-operator/packages/model/model.go index 3d16f3a84..aa68597f5 100644 --- a/k8-operator/packages/model/model.go +++ b/k8-operator/packages/model/model.go @@ -17,8 +17,9 @@ type RequestUpdateUpdateDetails struct { } type SingleEnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - ID string `json:"_id"` + Key string `json:"key"` + Value string `json:"value"` + SecretPath string `json:"secretPath"` + Type string `json:"type"` + ID string `json:"_id"` } diff --git a/k8-operator/packages/util/secrets.go b/k8-operator/packages/util/secrets.go index 9fb79c1de..b3325a701 100644 --- a/k8-operator/packages/util/secrets.go +++ b/k8-operator/packages/util/secrets.go @@ -69,10 +69,11 @@ func GetPlainTextSecretsViaMachineIdentity(infisicalClient infisical.InfisicalCl for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) } @@ -120,10 +121,11 @@ func GetPlainTextSecretsViaServiceToken(infisicalClient infisical.InfisicalClien for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) } @@ -183,10 +185,11 @@ func GetPlainTextSecretsViaServiceAccount(infisicalClient infisical.InfisicalCli for _, secret := range secrets { environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ - Key: secret.SecretKey, - Value: secret.SecretValue, - Type: secret.Type, - ID: secret.ID, + Key: secret.SecretKey, + Value: secret.SecretValue, + Type: secret.Type, + ID: secret.ID, + SecretPath: secret.SecretPath, }) } From 269f851cbfe94cd3299514a8dfa0553355d9bd2d Mon Sep 17 00:00:00 2001 From: = Date: Fri, 22 Nov 2024 00:08:30 +0530 Subject: [PATCH 02/70] docs: added docs for template support in k8s operator --- docs/integrations/platforms/kubernetes.mdx | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 8ea24d65f..a925f6c4b 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -162,6 +162,10 @@ spec: secretName: managed-secret secretNamespace: default creationPolicy: "Orphan" ## Owner | Orphan + # template: + # includeAllSecrets: true + # data: + # CUSTOM_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}" # secretType: kubernetes.io/dockerconfigjson ``` @@ -674,6 +678,51 @@ The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. + +Templates enable you to transform data from Infisical before storing it as a Kubernetes Secret. + + +When set to true, this option injects all secrets retrieved from Infisical into your configuration. +Secrets defined in the template will override the automatically injected secrets. + + +Define secret keys and their corresponding templates. +Each data value uses a Golang template with access to all secrets retrieved from the specified scope. + +Secrets are structured as follows: +```golang +type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` +} +``` + +#### Example template configuration: +```golang + managedSecretReference: + secretName: managed-secret + secretNamespace: default + template: + includeAllSecrets: true + data: + NEW_KEY: "{{ .KEY1.SecretPath }} {{ .KEY1.Value }}" +``` + +When you run the following command: +```bash +kubectl get secret managed-secret -o jsonpath='{.data}' +``` + +You'll receive Kubernetes secrets output that includes the NEW_KEY: +```bash +{... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="} +``` + +When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be: +```bash +{"NEW_KEY":"LyBoZWxsbw=="} +``` + Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. From e4b149a849a78031c9300e24075913d7009476d0 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 26 Nov 2024 21:19:32 +0530 Subject: [PATCH 03/70] feat: resolved csrf for oauth2 using state parameter --- backend/e2e-test/vitest-environment-knex.ts | 2 +- backend/src/@types/fastify.d.ts | 7 + backend/src/ee/routes/v1/oidc-router.ts | 4 +- backend/src/main.ts | 4 +- backend/src/server/app.ts | 5 +- backend/src/server/routes/v1/sso-router.ts | 134 ++++++++++++------ .../organizationInvitation.handlebars | 6 +- .../templates/workspaceInvitation.handlebars | 6 +- 8 files changed, 116 insertions(+), 52 deletions(-) diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 866b0f45f..e38921bbb 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -59,7 +59,7 @@ export default { const hsmModule = initializeHsmModule(); hsmModule.initialize(); - const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule() }); + const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule(), redis }); // @ts-expect-error type globalThis.testServer = server; diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2843648da..4221eadcb 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,5 +1,7 @@ import "fastify"; +import { Redis } from "ioredis"; + import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; @@ -87,6 +89,10 @@ import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; declare module "fastify" { + interface Session { + callbackPort: string; + } + interface FastifyRequest { realIp: string; // used for mfa session authentication @@ -115,6 +121,7 @@ declare module "fastify" { } interface FastifyInstance { + redis: Redis; services: { login: TAuthLoginFactory; password: TAuthPasswordFactory; diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index e675121e9..cd25c5be5 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -9,7 +9,6 @@ import { Authenticator, Strategy } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; -import { Redis } from "ioredis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; @@ -21,7 +20,6 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); - const redis = new Redis(appCfg.REDIS_URL); const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" }); /* @@ -30,7 +28,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { - Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js */ const redisStore = new RedisStore({ - client: redis, + client: server.redis, prefix: "oidc-session:", ttl: 600 // 10 minutes }); diff --git a/backend/src/main.ts b/backend/src/main.ts index 7f62d6b1e..02b20b6f5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import "./lib/telemetry/instrumentation"; import dotenv from "dotenv"; +import { Redis } from "ioredis"; import path from "path"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; @@ -57,11 +58,12 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); const queue = queueServiceFactory(appCfg.REDIS_URL); const keyStore = keyStoreFactory(appCfg.REDIS_URL); + const redis = new Redis(appCfg.REDIS_URL); const hsmModule = initializeHsmModule(); hsmModule.initialize(); - const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore }); + const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore, redis }); const bootstrap = await bootstrapCheck({ db }); // eslint-disable-next-line diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index cf7dd622a..b9e17525c 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -11,6 +11,7 @@ import helmet from "@fastify/helmet"; import type { FastifyRateLimitOptions } from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit"; import fastify from "fastify"; +import { Redis } from "ioredis"; import { Knex } from "knex"; import { Logger } from "pino"; @@ -39,10 +40,11 @@ type TMain = { queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; hsmModule: HsmModule; + redis: Redis; }; // Run the server! -export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore }: TMain) => { +export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore, redis }: TMain) => { const appCfg = getConfig(); const server = fastify({ @@ -56,6 +58,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + server.decorate("redis", redis); server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 9007ca828..a6c66ad60 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -8,6 +8,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; +import RedisStore from "connect-redis"; import { Strategy as GitHubStrategy } from "passport-github"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; @@ -21,8 +22,22 @@ import { AuthMethod } from "@app/services/auth/auth-type"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); - await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + const redisStore = new RedisStore({ + client: server.redis, + prefix: "oauth-session:", + ttl: 600 // 10 minutes + }); + + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, + cookie: { + secure: appCfg.HTTPS_ENABLED, + sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server + } + }); await server.register(passport.initialize()); await server.register(passport.secureSession()); // passport oauth strategy for Google @@ -35,11 +50,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`, - scope: ["profile", " email"] + scope: ["profile", " email"], + state: true }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { try { + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); + const email = profile?.emails?.[0]?.value; if (!email) throw new NotFoundError({ @@ -52,7 +71,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort: req.query.state as string + callbackPort }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -74,10 +93,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, - scope: ["user:email"] + scope: ["user:email"], + // akhilmhdh: because the ts type for this is outdated by the maintainer + state: true as unknown as string }, // eslint-disable-next-line async (req, accessToken, _refreshToken, profile, cb) => { + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; @@ -86,7 +109,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITHUB, - callbackPort: req.query.state as string + callbackPort }); return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -110,17 +133,20 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GITLAB_LOGIN, clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`, - baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL + baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL, + state: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { + const callbackPort = req.session.get("callbackPort"); + const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITLAB, - callbackPort: req.query.state as string + callbackPort }); return cb(null, { isUserCompleted, providerAuthToken }); @@ -141,17 +167,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + return ( + passport.authenticate("google", { + scope: ["profile", "email"], + authInfo: false + // this is due to zod type difference + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -164,7 +197,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { authInfo: false // this is due to zod type difference }) as never, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -184,15 +218,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("github", { - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + return ( + passport.authenticate("github", { + session: false, + authInfo: false + // this is due to zod type difference + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -205,7 +248,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { authInfo: false // this is due to zod type difference }) as any, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` @@ -225,16 +269,25 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callback_port: z.string().optional() }) }, - preValidation: (req, res) => - ( - passport.authenticate("gitlab", { - session: false, - state: req.query.callback_port, - authInfo: false - // this is due to zod type difference - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any - )(req, res), + preValidation: [ + async (req, res) => { + const { callback_port: callbackPort } = req.query; + // ensure fresh session state per login attempt + await req.session.regenerate(); + if (callbackPort) { + req.session.set("callbackPort", callbackPort); + } + + return ( + passport.authenticate("gitlab", { + session: false, + authInfo: false + // this is due to zod type difference + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + )(req, res); + } + ], handler: () => {} }); @@ -248,7 +301,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, - handler: (req, res) => { + handler: async (req, res) => { + await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index 3ee16ee37..c3ac9556d 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -8,9 +8,9 @@

Join your organization on Infisical

-

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}

- Join now +

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization named {{organizationName}}

+ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- \ No newline at end of file + diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index 39a9b74ba..b82b8b2c2 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -6,10 +6,10 @@

Join your team on Infisical

-

You have been invited to a new Infisical project — {{workspaceName}}

- Join now +

You have been invited to a new Infisical project named {{workspaceName}}

+ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- \ No newline at end of file + From 4c01bddf0e3874bcbd008bc3501a719b5b0fe7ff Mon Sep 17 00:00:00 2001 From: Alexandre Hamez <199517+ahamez@users.noreply.github.com> Date: Wed, 27 Nov 2024 09:26:56 +0100 Subject: [PATCH 04/70] doc: remove invalid links The documentation no longer contains information about deploying on AWS EC2 or DigitalOcean --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index d68481428..e5b493107 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,6 @@ Hiring (Remote/SF) -

- - - - - Deploy to DO - -

-

Infisical is released under the MIT license. From 5495ffd78e9a0152717f43726d9e132a08886c56 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 09:44:20 -0800 Subject: [PATCH 05/70] improvement: update add group to project modal to use filterable selects --- .../src/ee/services/license/license-fns.ts | 2 +- .../components/GroupsSection/GroupModal.tsx | 175 +++++++++--------- 2 files changed, 84 insertions(+), 93 deletions(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..accab79b5 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: false, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 3ece05497..ef1c89e58 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddGroupToWorkspace, @@ -16,8 +16,8 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - id: z.string(), - role: z.string() + group: z.object({ id: z.string(), name: z.string() }), + role: z.object({ slug: z.string(), name: z.string() }) }); export type FormData = z.infer; @@ -27,7 +27,9 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; }; -export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { +// TODO: update backend to support adding multiple roles at once + +const Content = ({ popUp, handlePopUpToggle }: Props) => { const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); @@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { resolver: zodResolver(schema) }); - const onFormSubmit = async ({ id, role }: FormData) => { + const onFormSubmit = async ({ group, role }: FormData) => { try { await addGroupToWorkspaceMutateAsync({ projectId: currentWorkspace?.id || "", - groupId: id, - role: role || undefined + groupId: group.id, + role: role.slug || undefined }); reset(); @@ -82,95 +84,84 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { } }; + return filteredGroupMembershipOrgs.length ? ( +
+ ( + + option.id} + getOptionLabel={(option) => option.name} + options={filteredGroupMembershipOrgs} + placeholder="Select group..." + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + options={roles} + placeholder="Select role..." + /> + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ ); +}; + +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("group", isOpen); - reset(); - }} + onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)} > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} + +
); From 9c03144f19d066e6252fd05d860f7c1832dd27bf Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 10:03:45 -0800 Subject: [PATCH 06/70] improvement: use filterable multi-select for add users to project role select --- .../MembersTab/components/AddMemberModal.tsx | 100 +++--------------- 1 file changed, 16 insertions(+), 84 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index fd0b13172..ed8271973 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -2,24 +2,11 @@ import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - FilterableSelect, - FormControl, - Modal, - ModalContent -} from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddUsersToOrg, @@ -33,7 +20,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; const addMemberFormSchema = z.object({ orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1), - projectRoleSlugs: z.array(z.string().trim().min(1)).min(1) + projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1) }); type TAddMemberForm = z.infer; @@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(addMemberFormSchema), - defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] } + defaultValues: { orgMemberships: [], projectRoleSlugs: [] } }); const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); @@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { { slug: currentWorkspace.slug, id: currentWorkspace.id, - projectRoleSlug: projectRoleSlugs + projectRoleSlug: projectRoleSlugs.map((role) => role.slug) } ] }); @@ -172,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - - - {roles && roles.length > 0 ? ( -
- {/* eslint-disable-next-line no-nested-ternary */} - {selectedRoleSlugs.length === 1 - ? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name - : selectedRoleSlugs.length === 0 - ? "Select at least one role" - : `${selectedRoleSlugs.length} roles selected`} - -
- ) : ( -
- No roles found -
- )} -
- - {roles && roles.length > 0 ? ( - roles.map((role) => { - const isSelected = selectedRoleSlugs.includes(role.slug); - - return ( - roles.length > 1 && event.preventDefault()} - onClick={() => { - if (selectedRoleSlugs.includes(String(role.slug))) { - field.onChange( - selectedRoleSlugs.filter( - (roleSlug: string) => roleSlug !== String(role.slug) - ) - ); - } else { - field.onChange([...selectedRoleSlugs, role.slug]); - } - }} - key={`role-slug-${role.slug}`} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {role.name} - - ); - }) - ) : ( -
- )} - - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> From d131314de00c72f9c8ebd3ec2b1d31c20fd06a87 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 10:21:52 -0800 Subject: [PATCH 07/70] improvement: filter select for invite users to org --- .../src/ee/services/license/license-fns.ts | 2 +- .../OrgMembersSection/AddOrgMemberModal.tsx | 37 ++++++++----------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index accab79b5..70c299564 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: true, + groups: false, status: null, trial_end: null, has_used_trial: true, diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 74aa5d7c2..2b90276de 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({ ) .default([]), projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), - organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG) + organizationRole: z.object({ name: z.string(), slug: z.string() }) }); type TAddMemberForm = z.infer; @@ -87,16 +87,16 @@ export const AddOrgMemberModal = ({ useEffect(() => { if (organizationRoles) { reset({ - organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!) - ? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug! - : currentOrg?.defaultMembershipRole + organizationRole: isCustomOrgRole(currentOrg?.defaultMembershipRole!) + ? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole) + : organizationRoles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) }); } }, [organizationRoles]); const onAddMembers = async ({ emails, - organizationRoleSlug, + organizationRole, projects: selectedProjects, projectRoleSlug }: TAddMemberForm) => { @@ -138,7 +138,7 @@ export const AddOrgMemberModal = ({ const { data } = await addUsersMutateAsync({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), - organizationRoleSlug, + organizationRoleSlug: organizationRole.slug, projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] })) }); @@ -207,27 +207,22 @@ export const AddOrgMemberModal = ({ ( + name="organizationRole" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
- -
+ option.slug} + getOptionLabel={(option) => option.name} + value={value} + onChange={onChange} + />
)} /> From 9ca58894f0805291aa1d5d504d6bb7fb5a4f8a7e Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 11:07:14 -0800 Subject: [PATCH 08/70] improvement: filter select for create identity role --- .../IdentitySection/IdentityModal.tsx | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 4b71aaea3..badab9a3c 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -9,27 +9,24 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, + FilterableSelect, FormControl, FormLabel, IconButton, Input, Modal, - ModalContent, - Select, - SelectItem + ModalContent } from "@app/components/v2"; import { useOrganization } from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; -import { - // IdentityAuthMethod, - useAddIdentityUniversalAuth -} from "@app/hooks/api/identities"; +import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z .object({ - name: z.string(), - role: z.string(), + name: z.string().min(1, "Required"), + role: z.object({ slug: z.string(), name: z.string() }), metadata: z .object({ key: z.string().trim().min(1), @@ -101,13 +98,15 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { if (identity) { reset({ name: identity.name, - role: identity?.customRole?.slug ?? identity.role, + role: identity?.customRole ?? roles.find((role) => role.slug === identity.role), metadata: identity.metadata }); } else { reset({ name: "", - role: roles[0].slug + role: isCustomOrgRole(currentOrg?.defaultMembershipRole!) + ? roles?.find((role) => role.id === currentOrg?.defaultMembershipRole) + : roles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) }); } }, [popUp?.identity?.data, roles]); @@ -126,7 +125,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { await updateMutateAsync({ identityId: identity.identityId, name, - role: role || undefined, + role: role.slug || undefined, organizationId: orgId, metadata }); @@ -137,7 +136,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, - role: role || undefined, + role: role.slug || undefined, organizationId: orgId, metadata }); @@ -184,7 +183,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset(); }} > - +
{ ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> From 8b3af92d23619cb84f4606f6e8da7235bfe5d1be Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 11:55:48 -0800 Subject: [PATCH 09/70] improvement: edit user role filterable select --- frontend/src/views/Org/UserPage/UserPage.tsx | 3 +- .../components/UserDetailsSection.tsx | 1 + .../components/UserOrgMembershipModal.tsx | 56 ++++++++++--------- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index ad0f66d6e..2b3817bcd 100644 --- a/frontend/src/views/Org/UserPage/UserPage.tsx +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -148,7 +148,8 @@ export const UserPage = withPermission( onClick={() => handlePopUpOpen("orgMembership", { membershipId: membership.id, - role: membership.role + role: membership.role, + roleId: membership.roleId }) } disabled={!isAllowed} diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx index d439c7ecd..6939eca17 100644 --- a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => handlePopUpOpen("orgMembership", { membershipId: membership.id, role: membership.role, + roleId: membership.roleId, metadata: membership.metadata }); }} diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index 57c8cebb2..9362881a4 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { SingleValue } from "react-select"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -8,21 +9,21 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, + FilterableSelect, FormControl, FormLabel, IconButton, Input, Modal, - ModalContent, - Select, - SelectItem + ModalContent } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - role: z.string(), + role: z.object({ name: z.string(), slug: z.string() }), metadata: z .object({ key: z.string().trim().min(1), @@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; - const { data: roles } = useGetOrgRoles(orgId); + const { data: roles = [] } = useGetOrgRoles(orgId); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); @@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg const popUpData = popUp?.orgMembership?.data as { membershipId: string; role: string; + roleId?: string; metadata: { key: string; value: string }[]; }; @@ -73,13 +75,18 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg if (!roles?.length) return; if (popUpData) { + console.log("roles", roles, popUpData.roleId); reset({ - role: popUpData.role, + role: popUpData.roleId + ? roles?.find((role) => role.id === popUpData.roleId) + : roles?.find((role) => role.slug === popUpData.role), metadata: popUpData.metadata }); } else { reset({ - role: roles[0].slug + role: isCustomOrgRole(currentOrg?.defaultMembershipRole!) + ? roles?.find((role) => role.id === currentOrg?.defaultMembershipRole) + : roles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) }); } }, [popUp?.orgMembership?.data, roles]); @@ -91,7 +98,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg await updateOrgMembership({ organizationId: orgId, membershipId: popUpData.membershipId, - role, + role: role.slug, metadata }); @@ -123,23 +130,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg reset(); }} > - + ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + value={value} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> )} /> From bcc2840020c5335d022b6fd5a6b3fad13cfa3aea Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 13:13:23 -0800 Subject: [PATCH 10/70] improvement: filterable role selection on create/edit group --- .../src/ee/services/license/license-fns.ts | 2 +- frontend/src/helpers/roles.ts | 5 ++- .../OrgGroupsSection/OrgGroupModal.tsx | 44 +++++++++---------- .../IdentitySection/IdentityModal.tsx | 8 ++-- .../components/UserOrgMembershipModal.tsx | 11 ++--- 5 files changed, 32 insertions(+), 38 deletions(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..accab79b5 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: false, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index de6291a13..580b635b4 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -1,4 +1,4 @@ -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types"; enum OrgMembershipRole { Admin = "admin", @@ -23,3 +23,6 @@ export const formatProjectRoleName = (name: string) => { export const isCustomProjectRole = (slug: string) => !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); + +export const findOrgMembershipRole = (roles: TOrgRole[], role: string) => + isCustomOrgRole(role) ? roles.find((r) => r.id === role) : roles.find((r) => r.slug === role); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx index 4ea4516de..b09c88763 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx @@ -6,14 +6,14 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, + FilterableSelect, FormControl, Input, Modal, - ModalContent, - Select, - SelectItem + ModalContent } from "@app/components/v2"; import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -23,7 +23,7 @@ const GroupFormSchema = z.object({ .string() .min(5, "Slug must be at least 5 characters long") .max(36, "Slug must be 36 characters or fewer"), - role: z.string() + role: z.object({ name: z.string(), slug: z.string() }) }); export type TGroupFormData = z.infer; @@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr reset({ name: group.name, slug: group.slug, - role: group?.customRole?.slug ?? group.role + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) }); } else { reset({ name: "", slug: "", - role: roles[0].slug + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) }); } }, [popUp?.group?.data, roles]); @@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr id: group.groupId, name, slug, - role: role || undefined + role: role.slug || undefined }); } else { await createMutateAsync({ name, slug, organizationId: currentOrg.id, - role: role || undefined + role: role.slug || undefined }); } handlePopUpToggle("group", false); @@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr reset(); }} > - + ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index badab9a3c..d483d0ea7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -18,7 +18,7 @@ import { ModalContent } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { isCustomOrgRole } from "@app/helpers/roles"; +import { findOrgMembershipRole } from "@app/helpers/roles"; import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -98,15 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { if (identity) { reset({ name: identity.name, - role: identity?.customRole ?? roles.find((role) => role.slug === identity.role), + role: identity.customRole ?? findOrgMembershipRole(roles, identity.role), metadata: identity.metadata }); } else { reset({ name: "", - role: isCustomOrgRole(currentOrg?.defaultMembershipRole!) - ? roles?.find((role) => role.id === currentOrg?.defaultMembershipRole) - : roles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) }); } }, [popUp?.identity?.data, roles]); diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index 9362881a4..289553ba8 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -18,7 +18,7 @@ import { ModalContent } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; -import { isCustomOrgRole } from "@app/helpers/roles"; +import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -75,18 +75,13 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg if (!roles?.length) return; if (popUpData) { - console.log("roles", roles, popUpData.roleId); reset({ - role: popUpData.roleId - ? roles?.find((role) => role.id === popUpData.roleId) - : roles?.find((role) => role.slug === popUpData.role), + role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role), metadata: popUpData.metadata }); } else { reset({ - role: isCustomOrgRole(currentOrg?.defaultMembershipRole!) - ? roles?.find((role) => role.id === currentOrg?.defaultMembershipRole) - : roles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!) }); } }, [popUp?.orgMembership?.data, roles]); From 4c739fd57fd2ef65507d44632d5ab05e85607e28 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 13:36:42 -0800 Subject: [PATCH 11/70] chore: revert license --- backend/src/ee/services/license/license-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index accab79b5..70c299564 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: true, + groups: false, status: null, trial_end: null, has_used_trial: true, From 8afa65c272528dfda8f7c3ccb1a7f331afc88df2 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 13:47:09 -0800 Subject: [PATCH 12/70] improvements: minor refactoring --- frontend/src/helpers/roles.ts | 6 ++++-- .../components/OrgMembersSection/AddOrgMemberModal.tsx | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index 580b635b4..4e26e1b15 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -24,5 +24,7 @@ export const formatProjectRoleName = (name: string) => { export const isCustomProjectRole = (slug: string) => !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); -export const findOrgMembershipRole = (roles: TOrgRole[], role: string) => - isCustomOrgRole(role) ? roles.find((r) => r.id === role) : roles.find((r) => r.slug === role); +export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) => + isCustomOrgRole(roleIdOrSlug) + ? roles.find((r) => r.id === roleIdOrSlug) + : roles.find((r) => r.slug === roleIdOrSlug); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 2b90276de..38faf53f1 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -15,7 +15,7 @@ import { TextArea } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { isCustomOrgRole } from "@app/helpers/roles"; +import { findOrgMembershipRole } from "@app/helpers/roles"; import { useAddUsersToOrg, useFetchServerStatus, @@ -87,9 +87,10 @@ export const AddOrgMemberModal = ({ useEffect(() => { if (organizationRoles) { reset({ - organizationRole: isCustomOrgRole(currentOrg?.defaultMembershipRole!) - ? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole) - : organizationRoles?.find((role) => role.slug === currentOrg?.defaultMembershipRole) + organizationRole: findOrgMembershipRole( + organizationRoles, + currentOrg?.defaultMembershipRole! + ) }); } }, [organizationRoles]); From 429366513022da3ac6244c153598663d4f5fc361 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 14:10:45 -0800 Subject: [PATCH 13/70] improvement: user groups table pagination --- .../UserProjectsSection/UserGroupsTable.tsx | 147 ++++++++++++++---- 1 file changed, 121 insertions(+), 26 deletions(-) diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 15299da26..999ffe794 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -1,6 +1,27 @@ -import { faFolder } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faMagnifyingGlass, + faSearch, + faUser +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrgUser } from "@app/hooks/api/types"; import { useListUserGroupMemberships } from "@app/hooks/api/users/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -12,31 +33,105 @@ type Props = { handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; }; -export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { - const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username); +enum UserGroupsOrderBy { + Name = "name" +} +export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { + const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships( + orgMembership.user.username + ); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 }); + + const filteredGroupMemberships = useMemo( + () => + groupMemberships + ?.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase())) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase()); + }), + [groupMemberships, orderDirection, search] + ); + + useResetPageHelper({ + totalCount: filteredGroupMemberships.length, + offset, + setPage + }); return ( - - - - - - - - - {groups?.map((group) => ( - - ))} - -
Name -
- {!isLoading && !groups?.length && ( - - )} -
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + {filteredGroupMemberships.slice(offset, perPage * page).map((group) => ( + + ))} + +
+
+ Name + + + +
+
+
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} +
+
); }; From dab8f0b2610f4d4cacf4691d62c40a7bd3eac676 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 14:29:41 -0800 Subject: [PATCH 14/70] improvement: secret tags table pagination --- .../UserProjectsSection/UserGroupsTable.tsx | 5 +- .../SecretTagsSection/SecretTagsSection.tsx | 3 +- .../SecretTagsSection/SecretTagsTable.tsx | 175 +++++++++++++----- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 999ffe794..af136d7ff 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -57,7 +57,7 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { const filteredGroupMemberships = useMemo( () => groupMemberships - ?.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase())) + .filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase())) .sort((a, b) => { const [membershipOne, membershipTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; @@ -72,13 +72,14 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { offset, setPage }); + return (
setSearch(e.target.value)} leftIcon={} - placeholder="Search projects..." + placeholder="Search groups..." /> diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index d1ba06835..26be8eb17 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -19,7 +19,6 @@ import { SecretTagsTable } from "./SecretTagsTable"; type DeleteModalData = { name: string; id: string }; export const SecretTagsSection = (): JSX.Element => { - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ "CreateSecretTag", "deleteTagConfirmation" @@ -65,7 +64,7 @@ export const SecretTagsSection = (): JSX.Element => { }} isDisabled={!isAllowed} > - Create tag + Create Tag )} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index cc68b0700..b6793ea1d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -1,10 +1,20 @@ -import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faMagnifyingGlass, + faSearch, + faTag, + faTrashCan +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, + Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -15,7 +25,9 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetWsTags } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -31,59 +43,124 @@ type Props = { ) => void; }; +enum TagsOrderBy { + Slug = "slug" +} + export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWsTags(currentWorkspace?.id ?? ""); + const { data: tags = [], isLoading } = useGetWsTags(currentWorkspace?.id ?? ""); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(TagsOrderBy.Slug, { initPerPage: 10 }); + + const filteredTags = useMemo( + () => + tags + .filter((tag) => tag.slug.toLowerCase().includes(search.trim().toLowerCase())) + .sort((a, b) => { + const [tagOne, tagTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + return tagOne.slug.toLowerCase().localeCompare(tagTwo.slug.toLowerCase()); + }), + [tags, orderDirection, search] + ); + + useResetPageHelper({ + totalCount: filteredTags.length, + offset, + setPage + }); return ( - -
- - - - - - - {isLoading && } - {!isLoading && - data && - data.map(({ id, slug }) => ( - - - - - ))} - {!isLoading && data && data?.length === 0 && ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search tags..." + /> + +
Slug -
{slug} - - {(isAllowed) => ( - - handlePopUpOpen("deleteTagConfirmation", { - name: slug, - id - }) - } - colorSchema="danger" - ariaLabel="update" - isDisabled={!isAllowed} - > - - - )} - -
+ - + + - )} - -
- - +
+ Slug + + + +
+
-
+ + + {isLoading && } + {!isLoading && + filteredTags.slice(offset, perPage * page).map(({ id, slug }) => ( + + {slug} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteTagConfirmation", { + name: slug, + id + }) + } + size="xs" + colorSchema="danger" + ariaLabel="update" + variant="plain" + isDisabled={!isAllowed} + > + + + )} + + + + ))} + + + {Boolean(filteredTags.length) && ( + + )} + {!isLoading && !filteredTags?.length && ( + + )} + +
); }; From a852b15a1e9e67cee00670dede4f67a925d3d6f0 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 15:32:31 -0800 Subject: [PATCH 15/70] improvement: move environment filters beneath static filters --- .../SecretOverviewPage/SecretOverviewPage.tsx | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index cb49f8509..56059e5bf 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -722,26 +722,6 @@ export const SecretOverviewPage = () => { - Choose visible environments - {userAvailableEnvs.map((availableEnv) => { - const { id: envId, name } = availableEnv; - - const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); - return ( - { - e.preventDefault(); - handleEnvSelect(envId); - }} - key={envId} - disabled={visibleEnvs?.length === 1} - icon={isEnvSelected && } - iconPos="right" - > -
{name}
-
- ); - })} {/*
+ Choose visible environments + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; + + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" + > +
{name}
+
+ ); + })} )} From a18f3c291903c90e7e78429357076b8fa349f485 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 29 Nov 2024 08:19:02 -0800 Subject: [PATCH 16/70] progress --- .../src/ee/services/license/license-fns.ts | 2 +- .../v2/FilterableSelect/FilterableSelect.tsx | 9 +- .../ApprovalPolicyList/ApprovalPolicyList.tsx | 2 +- .../components/AccessPolicyModal.tsx | 259 ++++++++---------- 4 files changed, 128 insertions(+), 144 deletions(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..ad49c4119 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -37,7 +37,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true, caCrl: false, instanceUserManagement: false, diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 450b58aef..bca2bf516 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -34,18 +34,19 @@ export const FilterableSelect = ({ tabSelectsValue={tabSelectsValue} components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }} classNames={{ - container: () => "w-full font-inter", + container: () => "w-full text-sm font-inter", control: ({ isFocused }) => twMerge( isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", "border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer" ), - placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5", + placeholder: () => + `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, input: () => "pl-1 py-0.5", valueContainer: () => `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, singleValue: () => "leading-7 ml-1", - multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5", + multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5", multiValueLabel: () => "leading-6 text-sm", multiValueRemove: () => "hover:text-red text-bunker-400", indicatorsContainer: () => "p-1 gap-1", @@ -53,7 +54,7 @@ export const FilterableSelect = ({ indicatorSeparator: () => "bg-bunker-400", dropdownIndicator: () => "text-bunker-200 p-1", menu: () => - "mt-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", + "my-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm", option: ({ isFocused, isSelected }) => twMerge( diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 936e6b981..a3e06459e 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { leftIcon={} isDisabled={!isAllowed} > - Create policy + Create Policy )} diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index e96cd3e2d..2f386f53b 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -1,18 +1,12 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, + FilterableSelect, FormControl, Input, Modal, @@ -46,21 +40,34 @@ type Props = { const formSchema = z .object({ - environment: z.string(), + environment: z.object({ slug: z.string(), name: z.string() }), name: z.string().optional(), secretPath: z.string().optional(), approvals: z.number().min(1), - approvers: z - .object({ type: z.nativeEnum(ApproverType), id: z.string() }) + userApprovers: z + .object({ type: z.literal(ApproverType.User), id: z.string() }) + .array() + .default([]), + groupApprovers: z + .object({ type: z.literal(ApproverType.Group), id: z.string() }) .array() - .min(1) .default([]), policyType: z.nativeEnum(PolicyType), enforcementLevel: z.nativeEnum(EnforcementLevel) }) - .refine((data) => data.approvers, { - path: ["approvers"], - message: "At least one approver should be provided." + .superRefine((data, ctx) => { + if (!(data.groupApprovers.length || data.userApprovers.length)) { + ctx.addIssue({ + path: ["userApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + ctx.addIssue({ + path: ["groupApprovers"], + code: z.ZodIssueCode.custom, + message: "At least one approver should be provided" + }); + } }); type TFormSchema = z.infer; @@ -84,8 +91,15 @@ export const AccessPolicyForm = ({ values: editValues ? { ...editValues, - environment: editValues.environment.slug, - approvers: editValues?.approvers || [], + environment: editValues.environment, + userApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [], + groupApprovers: + editValues?.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [], approvals: editValues?.approvals } : undefined @@ -110,18 +124,27 @@ export const AccessPolicyForm = ({ const approversRequired = watch("approvals") || 1; - const handleCreatePolicy = async (data: TFormSchema) => { + const handleCreatePolicy = async ({ + environment, + groupApprovers, + userApprovers, + ...data + }: TFormSchema) => { if (!projectId) return; try { if (data.policyType === PolicyType.ChangePolicy) { await createSecretApprovalPolicy({ ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, workspaceId: currentWorkspace?.id || "" }); } else { await createAccessApprovalPolicy({ ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, projectSlug }); } @@ -139,7 +162,12 @@ export const AccessPolicyForm = ({ } }; - const handleUpdatePolicy = async (data: TFormSchema) => { + const handleUpdatePolicy = async ({ + environment, + userApprovers, + groupApprovers, + ...data + }: TFormSchema) => { if (!projectId || !projectSlug) return; if (!editValues?.id) return; @@ -148,12 +176,15 @@ export const AccessPolicyForm = ({ await updateSecretApprovalPolicy({ id: editValues?.id, ...data, + approvers: [...userApprovers, ...groupApprovers], workspaceId: currentWorkspace?.id || "" }); } else { await updateAccessApprovalPolicy({ id: editValues?.id, ...data, + approvers: [...userApprovers, ...groupApprovers], + environment: environment.slug, projectSlug }); } @@ -179,9 +210,35 @@ export const AccessPolicyForm = ({ } }; + const memberOptions = useMemo( + () => + members.map(({ inviteEmail, user: { firstName, lastName, id: userId, username } }) => ({ + id: userId, + type: ApproverType.User, + label: + firstName || lastName + ? `${firstName ?? ""} ${lastName ?? ""}`.trim() + : username || inviteEmail + })), + [members] + ); + + const groupOptions = useMemo( + () => + groups?.map(({ group }) => ({ + id: group.id, + type: ApproverType.Group, + label: group.name + })), + [groups] + ); + return ( - +
( - + onChange={onChange} + placeholder="Select environment..." + options={environments} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> )} /> @@ -331,127 +381,60 @@ export const AccessPolicyForm = ({
( - - - e.type === ApproverType.User).length - ? `${value.filter((e) => e.type === ApproverType.User).length} selected` - : "None" - } - className="text-left" - /> - - - - Select members that are allowed to approve requests - - {members.map(({ user }) => { - const { id: userId } = user; - const isChecked = - value?.filter( - (el: { id: string; type: ApproverType }) => - el.id === userId && el.type === ApproverType.User - ).length > 0; - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter( - (el: { id: string; type: ApproverType }) => - el.id !== userId && el.type !== ApproverType.User - ) - : [...(value || []), { id: userId, type: ApproverType.User }] - ); - }} - key={`create-policy-members-${userId}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + + if (!member) return option.id; + + const { + inviteEmail, + user: { firstName, lastName, username, email } + } = member; + + return firstName || lastName + ? `${firstName ?? ""} ${lastName ?? ""}`.trim() + : username || email || inviteEmail; + }} + value={value} + onChange={onChange} + /> )} /> ( - - - e.type === ApproverType.Group).length - ? `${ - value?.filter((e) => e.type === ApproverType.Group).length - } selected` - : "None" - } - className="text-left" - /> - - - - Select groups that are allowed to approve requests - - {groups && - groups.map(({ group }) => { - const { id } = group; - const isChecked = - value?.filter( - (el: { id: string; type: ApproverType }) => - el.id === id && el.type === ApproverType.Group - ).length > 0; - - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter( - (el: { id: string; type: ApproverType }) => - el.id !== id && el.type !== ApproverType.Group - ) - : [...(value || []), { id, type: ApproverType.Group }] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {group.name} - - ); - })} - - + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> )} /> From bb094f60c1c1d0437392741d65e2214939eae78f Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 29 Nov 2024 10:44:05 -0800 Subject: [PATCH 17/70] improvement: update secret approval policy form to use filterable selects w/ UI revisions --- .../v2/FilterableSelect/FilterableSelect.tsx | 17 +- frontend/src/helpers/members.ts | 12 + .../ApprovalPolicyList/ApprovalPolicyList.tsx | 8 +- .../components/AccessPolicyModal.tsx | 284 +++++++++--------- .../components/ApprovalPolicyRow.tsx | 236 ++++----------- 5 files changed, 238 insertions(+), 319 deletions(-) create mode 100644 frontend/src/helpers/members.ts diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index bca2bf516..f17083248 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -34,17 +34,22 @@ export const FilterableSelect = ({ tabSelectsValue={tabSelectsValue} components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }} classNames={{ - container: () => "w-full text-sm font-inter", - control: ({ isFocused }) => + container: ({ isDisabled }) => + twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"), + control: ({ isFocused, isDisabled }) => twMerge( - isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", - "border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer" + isFocused ? "border-primary-400/50" : "border-mineshaft-600 ", + `border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${ + isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer" + } ` ), placeholder: () => `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, - input: () => "pl-1 py-0.5", + input: () => "pl-1", valueContainer: () => - `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, + `px-1 max-h-[8.2rem] ${ + isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]" + } gap-1`, singleValue: () => "leading-7 ml-1", multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5", multiValueLabel: () => "leading-6 text-sm", diff --git a/frontend/src/helpers/members.ts b/frontend/src/helpers/members.ts new file mode 100644 index 000000000..871ef3ec6 --- /dev/null +++ b/frontend/src/helpers/members.ts @@ -0,0 +1,12 @@ +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +export const getMemberLabel = (member: TWorkspaceUser) => { + const { + inviteEmail, + user: { firstName, lastName, username, email } + } = member; + + return firstName || lastName + ? `${firstName ?? ""} ${lastName ?? ""}`.trim() + : username || email || inviteEmail; +}; diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index a3e06459e..de8daec9e 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { Name Environment Secret Path - Eligible Approvers - Eligible Group Approvers + Eligible Approvers + Eligible Group Approvers Approval Required @@ -256,9 +256,9 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { {!!currentWorkspace && filteredPolicies?.map((policy) => (
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> - ( - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> - ( - - {field.value === EnforcementLevel.Hard - ? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.` - : `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`} -
- } - > - onChange(val as PolicyType)} + className="w-full border border-mineshaft-500" + > + {Object.values(PolicyType).map((policyType) => { + return ( + + {policyDetails[policyType].name} + + ); + })} + + + )} + /> + ( + - {Object.values(EnforcementLevel).map((level) => { - return ( - - {level} - - ); - })} - - - )} - /> + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + + + + )} + /> + ( + +

+ Determines the level of enforcement for required approvers of a request: +

+

+ Hard enforcement requires at least{" "} + {approversRequired} approver(s) to + approve the request.` +

+

+ Soft enforcement At least{" "} + {approversRequired} approver(s) must + approve the request; however, the requester can bypass approval + requirements in emergencies. +

+ + } + > + +
+ )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + + + )} + /> +

Approvers

@@ -399,14 +414,7 @@ export const AccessPolicyForm = ({ if (!member) return option.id; - const { - inviteEmail, - user: { firstName, lastName, username, email } - } = member; - - return firstName || lastName - ? `${firstName ?? ""} ${lastName ?? ""}`.trim() - : username || email || inviteEmail; + return getMemberLabel(member); }} value={value} onChange={onChange} diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 4b13029df..5b9a882cd 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -8,19 +8,19 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuTrigger, - Input, Td, + Tooltip, Tr } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; import { policyDetails } from "@app/helpers/policies"; -import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; -import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types"; +import { Approver } from "@app/hooks/api/accessApproval/types"; import { TGroupMembership } from "@app/hooks/api/groups/types"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; +import { ApproverType } from "@app/hooks/api/secretApproval/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; @@ -35,14 +35,14 @@ interface IPolicy { updatedAt: Date; policyType: PolicyType; enforcementLevel: EnforcementLevel; -}; +} type Props = { policy: IPolicy; members?: TWorkspaceUser[]; groups?: TGroupMembership[]; - projectSlug: string; - workspaceId: string; + // projectSlug: string; + // workspaceId: string; onEdit: () => void; onDelete: () => void; }; @@ -51,175 +51,69 @@ export const ApprovalPolicyRow = ({ policy, members = [], groups = [], - projectSlug, - workspaceId, + // projectSlug, + // workspaceId, onEdit, onDelete }: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - const [selectedGroupApprovers, setSelectedGroupApprovers] = useState(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); - const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); - const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; + // TODO(scott): add back to enable editing from modal? edit modal for policy is fine for now + // const [selectedApprovers, setSelectedApprovers] = useState( + // policy.approvers?.filter((approver) => approver.type === ApproverType.User) || [] + // ); + // const [selectedGroupApprovers, setSelectedGroupApprovers] = useState( + // policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || [] + // ); + // const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = + // useUpdateAccessApprovalPolicy(); + // const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = + // useUpdateSecretApprovalPolicy(); + // const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; + // + // const { permission } = useProjectPermission(); - const { permission } = useProjectPermission(); + const labels = useMemo(() => { + const usersInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id); + + const groupsInPolicy = policy.approvers + ?.filter((approver) => approver.type === ApproverType.Group) + .map((approver) => approver.id); + + const memberLabels = usersInPolicy?.length + ? members + .filter((member) => usersInPolicy?.includes(member.user.id)) + .map((member) => getMemberLabel(member)) + .join(", ") + : null; + + const groupLabels = groupsInPolicy?.length + ? groups + .filter(({ group }) => groupsInPolicy?.includes(group.id)) + .map(({ group }) => group.name) + .join(", ") + : null; + + return { + members: memberLabels, + groups: groupLabels + }; + }, [policy, members, groups]); return ( {policy.name} {policy.environment.slug} {policy.secretPath || "*"} - - { - if (!isOpen) { - if (policy.policyType === PolicyType.AccessPolicy) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - } - ); - } else { - updateSecretApprovalPolicy( - { - workspaceId, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - } - ); - } - } else { - setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); - } - }} - > - - - - - - Select members that are allowed to approve changes - - {members?.map(({ user }) => { - const userId = user.id; - const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0; - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }] - ); - }} - key={`create-policy-members-${userId}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - + + +

{labels.members ?? "-"}

+ - - { - if (!isOpen) { - if (policy.policyType === PolicyType.AccessPolicy) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - }, - ); - } else { - updateSecretApprovalPolicy( - { - workspaceId, - id: policy.id, - approvers: selectedApprovers.concat(selectedGroupApprovers), - }, - { - onError: () => { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - } - ); - } - } else { - setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); - } - }} - > - - - - - - Select groups that are allowed to approve requests - - {groups && groups.map(({ group }) => { - const { id } = group; - const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0; - return ( - { - evt.preventDefault(); - setSelectedGroupApprovers( - isChecked - ? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group) - : [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }] - ); - }} - key={`create-policy-groups-${id}`} - iconPos="right" - icon={isChecked && } - > - {group.name} - - ); - })} - - + + +

{labels.groups ?? "-"}

+
{policy.approvals} @@ -229,12 +123,12 @@ export const ApprovalPolicyRow = ({ - -
+ +
- + Date: Fri, 29 Nov 2024 10:44:26 -0800 Subject: [PATCH 18/70] chore: revert license --- backend/src/ee/services/license/license-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index ad49c4119..70c299564 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -37,7 +37,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true, caCrl: false, instanceUserManagement: false, From 62910e93ca18e3facaccbb0859abcfaf73423683 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 29 Nov 2024 10:52:49 -0800 Subject: [PATCH 19/70] fix: remove labels for options(outdated) --- backend/src/ee/services/license/license-fns.ts | 2 +- .../components/AccessPolicyModal.tsx | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..ad49c4119 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -37,7 +37,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true, caCrl: false, instanceUserManagement: false, diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index a00790a89..9f5aeec5a 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -213,13 +213,9 @@ export const AccessPolicyForm = ({ const memberOptions = useMemo( () => - members.map(({ inviteEmail, user: { firstName, lastName, id: userId, username } }) => ({ - id: userId, - type: ApproverType.User, - label: - firstName || lastName - ? `${firstName ?? ""} ${lastName ?? ""}`.trim() - : username || inviteEmail + members.map((member) => ({ + id: member.user.id, + type: ApproverType.User })), [members] ); @@ -228,8 +224,7 @@ export const AccessPolicyForm = ({ () => groups?.map(({ group }) => ({ id: group.id, - type: ApproverType.Group, - label: group.name + type: ApproverType.Group })), [groups] ); From ae51fbb8f279596cea05f13cea3f2ff41333d9bc Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 29 Nov 2024 10:53:22 -0800 Subject: [PATCH 20/70] chore: revert license --- backend/src/ee/services/license/license-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index ad49c4119..70c299564 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -37,7 +37,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true, caCrl: false, instanceUserManagement: false, From c8fba7ce4c4a298255484125a616c4c30ab8eca7 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 29 Nov 2024 11:17:54 -0800 Subject: [PATCH 21/70] improvement: align pagination left on grid view project overview --- frontend/src/components/v2/Pagination/Pagination.tsx | 2 +- frontend/src/pages/org/[id]/overview/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 51eed6396..2d0e8b1a9 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -54,7 +54,7 @@ export const Pagination = ({ )} > {startAdornment} -
+
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 45fc7f3d2..9e39fd389 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -876,7 +876,7 @@ const OrganizationPage = () => { Date: Fri, 29 Nov 2024 13:27:24 -0800 Subject: [PATCH 22/70] improvement: update copy secrets from env select and secret selection --- .../v2/FilterableSelect/FilterableSelect.tsx | 3 +- .../SecretDropzone/CopySecretsFromBoard.tsx | 177 +++++++----------- 2 files changed, 69 insertions(+), 111 deletions(-) diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index ad51f565d..d60df5b30 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -58,6 +58,7 @@ export const FilterableSelect = ({ clearIndicator: () => "p-1 hover:text-red text-bunker-400", indicatorSeparator: () => "bg-bunker-400", dropdownIndicator: () => "text-bunker-200 p-1", + menuList: () => "flex flex-col gap-1", menu: () => "mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm", @@ -65,7 +66,7 @@ export const FilterableSelect = ({ twMerge( isFocused && "bg-mineshaft-700 active:bg-mineshaft-600", isSelected && "text-mineshaft-200", - "hover:cursor-pointer mb-1 rounded text-xs px-3 py-2" + "hover:cursor-pointer rounded text-xs px-3 py-2" ), noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" }} diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx index fb5355298..e0e8b9e68 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx @@ -1,14 +1,7 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; -import { - faClone, - faFileImport, - faKey, - faSearch, - faSquareCheck, - faSquareXmark -} from "@fortawesome/free-solid-svg-icons"; +import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -16,17 +9,13 @@ import { z } from "zod"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, - Checkbox, - EmptyState, + FilterableSelect, FormControl, IconButton, - Input, Modal, ModalContent, ModalTrigger, - Select, - SelectItem, - Skeleton, + Switch, Tooltip } from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; @@ -35,14 +24,14 @@ import { useDebounce } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; const formSchema = z.object({ - environment: z.string().trim(), + environment: z.object({ name: z.string(), slug: z.string() }), secretPath: z .string() .trim() .transform((val) => typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val ), - secrets: z.record(z.string().optional().nullable()) + secrets: z.object({ key: z.string(), value: z.string().optional() }).array().min(1) }); type TFormSchema = z.infer; @@ -68,7 +57,6 @@ export const CopySecretsFromBoard = ({ onToggle, onParsedEnv }: Props) => { - const [searchFilter, setSearchFilter] = useState(""); const [shouldIncludeValues, setShouldIncludeValues] = useState(true); const { @@ -80,7 +68,7 @@ export const CopySecretsFromBoard = ({ formState: { isDirty } } = useForm({ resolver: zodResolver(formSchema), - defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + defaultValues: { secretPath: "/", environment: environments?.[0] } }); const envCopySecPath = watch("secretPath"); @@ -89,7 +77,7 @@ export const CopySecretsFromBoard = ({ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ workspaceId, - environment: selectedEnvSlug, + environment: selectedEnvSlug.slug, secretPath: debouncedEnvCopySecretPath, options: { enabled: @@ -101,29 +89,22 @@ export const CopySecretsFromBoard = ({ }); useEffect(() => { - setValue("secrets", {}); - setSearchFilter(""); - }, [debouncedEnvCopySecretPath]); + setValue("secrets", []); + }, [debouncedEnvCopySecretPath, selectedEnvSlug]); const handleSecSelectAll = () => { if (secrets) { - setValue( - "secrets", - secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), - { shouldDirty: true } - ); + setValue("secrets", secrets, { shouldDirty: true }); } }; const handleFormSubmit = async (data: TFormSchema) => { const secretsToBePulled: Record = {}; - Object.keys(data.secrets || {}).forEach((key) => { - if (data.secrets[key]) { - secretsToBePulled[key] = { - value: (shouldIncludeValues && data.secrets[key]) || "", - comments: [""] - }; - } + data.secrets.forEach(({ key, value }) => { + secretsToBePulled[key] = { + value: (shouldIncludeValues && value) || "", + comments: [""] + }; }); onParsedEnv(secretsToBePulled); onToggle(false); @@ -136,7 +117,6 @@ export const CopySecretsFromBoard = ({ onOpenChange={(state) => { onToggle(state); reset(); - setSearchFilter(""); }} > @@ -176,22 +156,14 @@ export const CopySecretsFromBoard = ({ name="environment" render={({ field: { value, onChange } }) => ( - + onChange={onChange} + options={environments} + placeholder="Select environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> )} /> @@ -203,7 +175,7 @@ export const CopySecretsFromBoard = ({ )} @@ -212,72 +184,57 @@ export const CopySecretsFromBoard = ({
Secrets
-
- +
+ ( + + option.key} + getOptionLabel={(option) => option.key} + /> + + )} + /> + + } - onChange={(evt) => setSearchFilter(evt.target.value)} - /> - - - - - - - reset()} - > - - - -
+ onClick={handleSecSelectAll} + > + + +
- {!isSecretsLoading && !secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} - - {secrets - ?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase())) - ?.map(({ id, key, value: secVal }) => ( - ( - onChange(isChecked ? secVal : "")} - > - {key} - - )} - /> - ))} -
-
- + setShouldIncludeValues(isChecked as boolean)} > Include secret values - +
Secrets
-
+
Date: Mon, 2 Dec 2024 09:35:03 -0800 Subject: [PATCH 26/70] improvement: address feedback --- .../ApprovalPolicyList/ApprovalPolicyList.tsx | 2 -- .../components/ApprovalPolicyRow.tsx | 29 +++++-------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index de8daec9e..d2a4e3788 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -256,9 +256,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { {!!currentWorkspace && filteredPolicies?.map((policy) => ( void; onDelete: () => void; }; @@ -51,26 +49,9 @@ export const ApprovalPolicyRow = ({ policy, members = [], groups = [], - // projectSlug, - // workspaceId, onEdit, onDelete }: Props) => { - // TODO(scott): add back to enable editing from modal? edit modal for policy is fine for now - // const [selectedApprovers, setSelectedApprovers] = useState( - // policy.approvers?.filter((approver) => approver.type === ApproverType.User) || [] - // ); - // const [selectedGroupApprovers, setSelectedGroupApprovers] = useState( - // policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || [] - // ); - // const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = - // useUpdateAccessApprovalPolicy(); - // const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = - // useUpdateSecretApprovalPolicy(); - // const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; - // - // const { permission } = useProjectPermission(); - const labels = useMemo(() => { const usersInPolicy = policy.approvers ?.filter((approver) => approver.type === ApproverType.User) @@ -106,12 +87,18 @@ export const ApprovalPolicyRow = ({ {policy.environment.slug} {policy.secretPath || "*"} - +

{labels.members ?? "-"}

- +

{labels.groups ?? "-"}

From d1b9c316d8d30e593d55bade01f6abf54965aada Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 28 Nov 2024 15:23:57 -0800 Subject: [PATCH 27/70] improvement: use multi-select for environment selection on create secret --- .../SecretOverviewPage/SecretOverviewPage.tsx | 1 - .../CreateSecretForm/CreateSecretForm.tsx | 139 +++++++----------- 2 files changed, 51 insertions(+), 89 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index cb49f8509..f4c83e2d0 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1128,7 +1128,6 @@ export const SecretOverviewPage = () => { > handlePopUpClose("addSecretsInAllEnvs")} /> diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 0716309d3..146d2035d 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -1,13 +1,13 @@ import { ClipboardEvent } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; -import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, FormControl, FormLabel, Input, Tooltip } from "@app/components/v2"; +import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2"; import { CreatableSelect } from "@app/components/v2/CreatableSelect"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { @@ -17,20 +17,14 @@ import { useWorkspace } from "@app/context"; import { getKeyValue } from "@app/helpers/parseEnvVar"; -import { - useCreateFolder, - useCreateSecretV3, - useCreateWsTag, - useGetWsTags, - useUpdateSecretV3 -} from "@app/hooks/api"; -import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; +import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; +import { SecretType } from "@app/hooks/api/types"; const typeSchema = z .object({ key: z.string().trim().min(1, "Key is required"), value: z.string().optional(), - environments: z.record(z.boolean().optional()), + environments: z.object({ name: z.string(), slug: z.string() }).array(), tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional() }) .refine((data) => data.key !== undefined, { @@ -41,22 +35,19 @@ type TFormSchema = z.infer; type Props = { secretPath?: string; - getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; // modal props onClose: () => void; }; -export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: Props) => { +export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { const { register, handleSubmit, control, reset, - watch, setValue, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(typeSchema) }); - const newSecretKey = watch("key"); const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); @@ -65,22 +56,14 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: const environments = currentWorkspace?.environments || []; const { mutateAsync: createSecretV3 } = useCreateSecretV3(); - const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + // const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags( canReadTags ? workspaceId : "" ); const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => { - const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]); - const isEnvironmentsSelected = environmentsSelected.length; - - if (!isEnvironmentsSelected) { - createNotification({ type: "error", text: "Select at least one environment" }); - return; - } - - const promises = environmentsSelected.map(async (env) => { + const promises = selectedEnv.map(async (env) => { const environment = env.slug; // create folder if not existing if (secretPath !== "/") { @@ -106,21 +89,22 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: } } - const isEdit = getSecretByKey(environment, key) !== undefined; - if (isEdit) { - return { - ...(await updateSecretV3({ - environment, - workspaceId, - secretPath, - secretKey: key, - secretValue: value || "", - type: SecretType.Shared, - tagIds: tags?.map((el) => el.value) - })), - environment - }; - } + // TODO: add back - need to fetch secrets by key to check for conflicts as this method broke with pagination + // const isEdit = getSecretByKey(environment, key) !== undefined; + // if (isEdit) { + // return { + // ...(await updateSecretV3({ + // environment, + // workspaceId, + // secretPath, + // secretKey: key, + // secretValue: value || "", + // type: SecretType.Shared, + // tagIds: tags?.map((el) => el.value) + // })), + // environment + // }; + // } return { ...(await createSecretV3({ @@ -278,54 +262,33 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: )} /> - -
- {environments - .filter((environmentSlug) => - permission.can( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { - environment: environmentSlug.slug, - secretPath, - secretName: "*", - secretTags: ["*"] - }) - ) - ) - .map((env) => { - return ( - ( - - - - {env.name} - - - {getSecretByKey(env.slug, newSecretKey) && ( - - - - )} - - - - )} - /> - ); - })} -
+ ( + + + permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: environment.slug, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ) + )} + value={value} + onChange={onChange} + placeholder="Select environments to create secret in..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> + + )} + name="environments" + />
+ +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx index d1de3a2e5..1bdf2757d 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx @@ -1,4 +1,4 @@ -import { faAws } from "@fortawesome/free-brands-svg-icons"; +import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faEllipsis, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -117,9 +117,12 @@ export const OrgEncryptionTab = withPermission( externalKmsList?.map((kms) => ( - {kms.externalKms.provider === ExternalKmsProvider.AWS && ( + {kms.externalKms.provider === ExternalKmsProvider.Aws && ( )} + {kms.externalKms.provider === ExternalKmsProvider.Gcp && ( + + )}
{kms.externalKms.provider.toUpperCase()}
{kms.name} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx index 80e171167..bc2e85558 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx @@ -3,6 +3,7 @@ import { useGetExternalKmsById } from "@app/hooks/api"; import { ExternalKmsProvider } from "@app/hooks/api/kms/types"; import { AwsKmsForm } from "./AwsKmsForm"; +import { GcpKmsForm } from "./GcpKmsForm"; type Props = { isOpen: boolean; @@ -14,15 +15,22 @@ export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) => const { data: externalKms, isLoading } = useGetExternalKmsById(kmsId); return ( - + {isLoading && } - {externalKms?.external?.provider === ExternalKmsProvider.AWS && ( + {externalKms?.external?.provider === ExternalKmsProvider.Aws && ( onOpenChange(false)} onCompleted={() => onOpenChange(false)} /> )} + {externalKms?.external?.provider === ExternalKmsProvider.Gcp && ( + onOpenChange(false)} + onCompleted={() => onOpenChange(false)} + /> + )} ); From 6c533f89d317257e8d7738894fbe49240048cdc4 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 3 Dec 2024 14:53:33 -0800 Subject: [PATCH 34/70] feature: high-level integrations refactor --- .../public/images/integrations/GitHub.png | Bin 8640 -> 23562 bytes .../gcp-secret-manager/authorize.tsx | 6 + .../github/auth-mode-selection.tsx | 10 + .../pages/integrations/gitlab/authorize.tsx | 6 + .../integrations/select-integration-auth.tsx | 6 + .../IntegrationPage.utils.tsx | 47 ++ .../IntegrationsPage/IntegrationsPage.tsx | 91 +++- .../CloudIntegrationSection.tsx | 29 +- .../FrameworkIntegrationSection.tsx | 55 +-- .../IntegrationsSection.tsx | 79 ++-- .../components/IntegrationDetails.tsx | 137 ++++++ .../components/IntegrationRow.tsx | 191 ++++++++ .../components/IntegrationsTable.tsx | 415 ++++++++++++++++++ .../IntegrationsSection/components/index.ts | 1 + 14 files changed, 968 insertions(+), 105 deletions(-) create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts diff --git a/frontend/public/images/integrations/GitHub.png b/frontend/public/images/integrations/GitHub.png index 9490ffc6d2e158b266f719d58365905c1c5060cb..7492fcb54ae190dd7ec9861b6de90d0818470a26 100644 GIT binary patch literal 23562 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 literal 8640 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 diff --git a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx index ad82a218c..cea246f2f 100644 --- a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx +++ b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx @@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2"; @@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() { const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`; window.location.assign(link); }; diff --git a/frontend/src/pages/integrations/github/auth-mode-selection.tsx b/frontend/src/pages/integrations/github/auth-mode-selection.tsx index 5fcb4f0dd..4a512ff60 100644 --- a/frontend/src/pages/integrations/github/auth-mode-selection.tsx +++ b/frontend/src/pages/integrations/github/auth-mode-selection.tsx @@ -18,6 +18,7 @@ import { SelectItem } from "@app/components/v2"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; enum AuthMethod { APP = "APP", @@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() { if (selectedAuthMethod === AuthMethod.APP) { router.push("/integrations/select-integration-auth?integrationSlug=github"); } else { + if (!githubIntegration?.clientId) { + createIntegrationMissingEnvVarsNotification( + "githubactions", + "cicd", + "connecting-with-github-oauth" + ); + return; + } + const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); diff --git a/frontend/src/pages/integrations/gitlab/authorize.tsx b/frontend/src/pages/integrations/gitlab/authorize.tsx index 380aad08e..d6c80c2c1 100644 --- a/frontend/src/pages/integrations/gitlab/authorize.tsx +++ b/frontend/src/pages/integrations/gitlab/authorize.tsx @@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; @@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() { if (!integrationOption) return; + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } + const baseURL = (gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim(); diff --git a/frontend/src/pages/integrations/select-integration-auth.tsx b/frontend/src/pages/integrations/select-integration-auth.tsx index a9d2766a4..1f9f17afa 100644 --- a/frontend/src/pages/integrations/select-integration-auth.tsx +++ b/frontend/src/pages/integrations/select-integration-auth.tsx @@ -13,6 +13,7 @@ import { useGetOrgIntegrationAuths } from "@app/hooks/api"; import { IntegrationAuth } from "@app/hooks/api/types"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; export default function SelectIntegrationAuthPage() { const router = useRouter(); @@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() { localStorage.setItem("latestCSRFToken", state); if (integrationSlug === "github") { + if (!currentIntegration?.clientSlug) { + createIntegrationMissingEnvVarsNotification("githubactions", "cicd"); + return; + } + // for now we only handle Github apps window.location.assign( `https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}` diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index b20b15a09..e1a1ff6fb 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { createNotification } from "@app/components/notifications"; import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types"; import { @@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) = return { encryptedKey: ciphertext, nonce }; }; +export const createIntegrationMissingEnvVarsNotification = ( + slug: string, + type: "cloud" | "cicd" = "cloud", + hashtag?: string +) => + createNotification({ + type: "error", + text: ( +
+ Click here to view docs + + ), + title: "Missing Environment Variables" + }); + export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => { try { // generate CSRF token for OAuth2 code-token exchange integrations @@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`; break; case "azure-key-vault": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; break; case "azure-app-configuration": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`; break; case "aws-parameter-store": @@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/aws-secret-manager/authorize`; break; case "heroku": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; break; case "vercel": + if (!integrationOption.clientSlug) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; break; case "netlify": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; break; case "github": @@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/cloudflare-workers/authorize`; break; case "bitbucket": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`; break; case "codefresh": diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx index e44fd2539..3249e1eb5 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { motion } from "framer-motion"; import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { @@ -28,11 +30,17 @@ type Props = { }>; }; +enum IntegrationView { + List = "list", + New = "new" +} + export const IntegrationsPage = withProjectPermission( ({ frameworkIntegrations, infrastructureIntegrations }: Props) => { const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; const environments = currentWorkspace?.environments || []; + const [view, setView] = useState(IntegrationView.New); const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } = useGetCloudIntegrations(); @@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission( const { data: integrations, isLoading: isIntegrationLoading, - isFetching: isIntegrationFetching + isFetching: isIntegrationFetching, + isFetched: isIntegrationsFetched } = useGetWorkspaceIntegrations(workspaceId); const { mutateAsync: deleteIntegration } = useDeleteIntegration(); @@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission( isIntegrationsEmpty ]); + useEffect(() => { + setView(integrations?.length ? IntegrationView.List : IntegrationView.New); + }, [isIntegrationsFetched]); + const handleProviderIntegration = async (provider: string) => { const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); if (!selectedCloudIntegration) return; @@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission( } }; + if (isIntegrationLoading || isCloudIntegrationsLoading) + return ( +
+ +
+ ); + return ( -
- - - - +
+
+ {view === IntegrationView.List ? ( + + setView(IntegrationView.New)} + isLoading={isIntegrationLoading} + integrations={integrations} + environments={environments} + onIntegrationDelete={handleIntegrationDelete} + workspaceId={workspaceId} + /> + + ) : ( + + setView(IntegrationView.List) : undefined + } + isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading} + cloudIntegrations={cloudIntegrations} + integrationAuths={integrationAuths} + onIntegrationStart={handleProviderIntegrationStart} + onIntegrationRevoke={handleIntegrationAuthRevoke} + /> + + + + )} +
); }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations } + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Integrations + } ); diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx index 164470289..b9d51c303 100644 --- a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -1,11 +1,11 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faChevronLeft, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner"; import { createNotification } from "@app/components/notifications"; -import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2"; +import { Button, DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -22,6 +22,7 @@ type Props = { onIntegrationStart: (slug: string) => void; // cb: handle popUpClose child->parent communication pattern onIntegrationRevoke: (slug: string, cb: () => void) => void; + onViewActiveIntegrations?: () => void; }; type TRevokeIntegrationPopUp = { provider: string }; @@ -31,7 +32,8 @@ export const CloudIntegrationSection = ({ cloudIntegrations = [], integrationAuths = {}, onIntegrationStart, - onIntegrationRevoke + onIntegrationRevoke, + onViewActiveIntegrations }: Props) => { const { t } = useTranslation(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -60,11 +62,19 @@ export const CloudIntegrationSection = ({ )}
+ {onViewActiveIntegrations && ( + + )}

{t("integrations.cloud-integrations")}

{t("integrations.click-to-start")}

- -
+
{isLoading && Array.from({ length: 12 }).map((_, index) => ( @@ -79,7 +89,7 @@ export const CloudIntegrationSection = ({ cloudIntegration.isAvailable ? "cursor-pointer duration-200 hover:bg-mineshaft-700" : "opacity-50" - } flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} + } flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} onClick={() => { if (!cloudIntegration.isAvailable) return; if ( @@ -100,11 +110,12 @@ export const CloudIntegrationSection = ({ > integration logo -
+
{cloudIntegration.name}
{cloudIntegration.isAvailable && diff --git a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx index 3b1df9bdd..a4e6bb586 100644 --- a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx @@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {

{t("integrations.framework-integrations")}

{t("integrations.click-to-setup")}

-
+
{sortedFrameworks.map((framework) => ( -
1 ? "px-1 text-sm" : "px-2 text-xl" - } w-full max-w-xs text-center`} - > - {framework?.image && ( - integration logo - )} - {framework?.name && framework?.image &&
} - {framework?.name && framework.name} -
+ {framework?.image && ( + integration logo + )} + {framework?.name && ( +
+ {framework.name} +
+ )}
))} { href="https://infisical.com/docs/cli/commands/run" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
CLI
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => { href="https://infisical.com/docs/sdks/overview" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
SDKs
diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 2fbcca15d..a09535f03 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,13 +1,16 @@ -import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2"; -import { usePopUp, useToggle } from "@app/hooks"; -import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; -import { TIntegration } from "@app/hooks/api/types"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem"; +import { Button, Checkbox, DeleteActionModal } from "@app/components/v2"; +import { usePopUp, useToggle } from "@app/hooks"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/types"; + +import { IntegrationsTable } from "./components"; type Props = { environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; isLoading?: boolean; onIntegrationDelete: ( integrationId: string, @@ -15,6 +18,7 @@ type Props = { cb: () => void ) => Promise; workspaceId: string; + onAddIntegration: () => void; }; export const IntegrationsSection = ({ @@ -22,58 +26,47 @@ export const IntegrationsSection = ({ environments = [], isLoading, onIntegrationDelete, - workspaceId + workspaceId, + onAddIntegration, + cloudIntegrations = [] }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteConfirmation", "deleteSecretsConfirmation" ] as const); - const { mutate: syncIntegration } = useSyncIntegration(); const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); return (
-

Current Integrations

+

Integrations

Manage integrations with third-party services.

- {isLoading && ( -
- +
+
+

Active Integrations

+
- )} - - {!isLoading && !integrations.length && ( -
- -
- )} - {!isLoading && ( -
- {integrations?.map((integration) => ( - { - syncIntegration({ - workspaceId, - id: integration.id, - lastUsed: integration.lastUsed as string - }); - }} - onRemoveIntegration={() => { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - integration={integration} - environments={environments} - /> - ))} -
- )} + { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + /> +
{ + return ( +
+ {integration.integration === "octopus-deploy" && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "qovery" && ( + <> +
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+ + )} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
+
+ )} + {(integration.integration === "vercel" || + integration.integration === "netlify" || + integration.integration === "railway" || + integration.integration === "gitlab" || + integration.integration === "teamcity" || + (integration.integration === "github" && integration.scope === "github-env")) && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "bitbucket" && ( + <> + {integration.targetServiceId && ( +
+ +
+ {integration.targetService || integration.targetServiceId} +
+
+ )} +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ + )} + {integration.integration === "checkly" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {integration.integration === "circleci" && integration.owner && ( +
+ +
{integration.owner}
+
+ )} + {integration.integration === "terraform-cloud" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {(integration.integration === "checkly" || integration.integration === "github") && ( +
+ +
{integration?.metadata?.secretSuffix || "-"}
+
+ )} + {integration.integration === "github" && integration.metadata?.githubVisibility ? ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {integration.metadata?.githubVisibility === "selected" + ? "* Syncing to selected repositories in the organization. " + : integration.metadata?.githubVisibility === "private" + ? "* Syncing to all private repositories in the organization" + : "* Syncing to all public and private repositories in the organization"} +
+ ) : undefined} +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx new file mode 100644 index 000000000..5579c7f76 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx @@ -0,0 +1,191 @@ +import { useMemo } from "react"; +import { useRouter } from "next/router"; +import { + faCalendarCheck, + faCheck, + faInfoCircle, + faRefresh, + faTrash, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { TCloudIntegration } from "@app/hooks/api/integrations/types"; +import { TIntegration } from "@app/hooks/api/types"; + +import { IntegrationDetails } from "./IntegrationDetails"; + +type IProps = { + integration: TIntegration; + environment?: { name: string; slug: string; id: string }; + onRemoveIntegration: VoidFunction; + onManualSyncIntegration: VoidFunction; + cloudIntegration: TCloudIntegration; +}; + +export const IntegrationRow = ({ + integration, + environment, + onRemoveIntegration, + onManualSyncIntegration, + cloudIntegration +}: IProps) => { + const router = useRouter(); + + const { id, secretPath, syncMessage, isSynced } = integration; + + const failureMessage = useMemo(() => { + if (isSynced === false) { + if (syncMessage) + try { + // format if json + return JSON.stringify(JSON.parse(syncMessage), null, 2); + } catch (e) { + return syncMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [isSynced, syncMessage]); + + return ( + router.push(`/integrations/details/${integration.id}`)} + className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`integration-${id}`} + > + +
+ {`${cloudIntegration?.name} + {cloudIntegration?.name} +
+ + + +

{secretPath}

+
{" "} + + {environment?.name ?? "-"} + +
+

+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +

+ } + > + + +
+ + + {" "} + {typeof integration.isSynced !== "boolean" ? ( + + Pending Sync + + ) : ( + + {integration.lastUsed && ( +
+
+ +
Last Synced
+
+
+ {format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")} +
+
+ )} + {failureMessage && ( +
+
+ +
Failure Reason
+
+
{failureMessage}
+
+ )} +
+ } + > +
+ +
+ +
{integration.isSynced ? "Synced" : "Not Synced"}
+
+
+
+ + )} + + +
+ + { + e.stopPropagation(); + onManualSyncIntegration(); + }} + ariaLabel="sync" + colorSchema="secondary" + variant="plain" + > + + + + + {(isAllowed: boolean) => ( + + { + e.stopPropagation(); + onRemoveIntegration(); + }} + ariaLabel="delete" + isDisabled={!isAllowed} + colorSchema="danger" + variant="plain" + > + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx new file mode 100644 index 000000000..f61624451 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx @@ -0,0 +1,415 @@ +import { useMemo, useState } from "react"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDown, + faArrowUp, + faCheck, + faClock, + faFilter, + faMagnifyingGlass, + faPlug, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types"; + +import { IntegrationRow } from "./IntegrationRow"; + +type Props = { + integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; + workspaceId: string; + isLoading?: boolean; + environments: Array<{ name: string; slug: string; id: string }>; + onDeleteIntegration: (integration: TIntegration) => void; +}; + +enum IntegrationsOrderBy { + App = "app", + Status = "status", + SecretPath = "secretPath", + Environment = "environment" +} + +enum IntegrationStatus { + Synced = "synced", + NotSynced = "not-synced", + PendingSync = "pending-sync" +} + +type IntegrationFilters = { + environmentIds: string[]; + integrations: string[]; + status: IntegrationStatus[]; +}; + +const STATUS_ICON_MAP = { + [IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" }, + [IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" }, + [IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" } +}; + +export const IntegrationsTable = ({ + integrations = [], + cloudIntegrations = [], + workspaceId, + environments, + onDeleteIntegration, + isLoading +}: Props) => { + const { mutate: syncIntegration } = useSyncIntegration(); + + const initialFilters = useMemo( + () => ({ + environmentIds: environments.map((env) => env.id), + integrations: [...new Set(integrations.map(({ integration }) => integration))], + status: Object.values(IntegrationStatus) + }), + [environments, integrations] + ); + + const [filters, setFilters] = useState(initialFilters); + + const cloudIntegrationMap = useMemo(() => { + return new Map( + cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration]) + ); + }, [cloudIntegrations]); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(IntegrationsOrderBy.Status, { initPerPage: 20 }); + + const environmentMap = new Map(environments.map((env) => [env.id, env])); + + const filteredIntegrations = useMemo( + () => + integrations + .filter(({ integration, secretPath, envId, isSynced }) => { + if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false; + if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false) + return false; + if ( + !filters.status.includes(IntegrationStatus.PendingSync) && + typeof isSynced !== "boolean" + ) + return false; + + if (!filters.integrations.includes(integration)) return false; + + return ( + integration.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) || + secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) || + environmentMap + .get(envId) + ?.name.replace("-", " ") + .toLowerCase() + .includes(search.trim().toLowerCase()) + ); + }) + .sort((a, b) => { + const [integrationOne, integrationTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case IntegrationsOrderBy.SecretPath: + return integrationOne.secretPath + .toLowerCase() + .localeCompare(integrationTwo.secretPath.toLowerCase()); + case IntegrationsOrderBy.Environment: + return (environmentMap.get(integrationOne.envId)?.name ?? "-") + .toLowerCase() + .localeCompare( + (environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase() + ); + case IntegrationsOrderBy.Status: + if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end + if (typeof integrationTwo.isSynced !== "boolean") return -1; + + return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced); + case IntegrationsOrderBy.App: + default: + return integrationOne.integration + .toLowerCase() + .localeCompare(integrationTwo.integration.toLowerCase()); + } + }), + [integrations, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredIntegrations.length, + offset, + setPage + }); + + const handleSort = (column: IntegrationsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: IntegrationsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: IntegrationsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = + filters.integrations.length !== initialFilters.integrations.length || + filters.environmentIds.length !== initialFilters.environmentIds.length || + filters.status.length !== initialFilters.status.length; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search integrations..." + className="flex-1" + /> + + + + + + + + + + Status + {Object.values(IntegrationStatus).map((status) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + status: prev.status.includes(status) + ? prev.status.filter((s) => s !== status) + : [...prev.status, status] + })); + }} + key={status} + icon={ + filters.status.includes(status) && ( + + ) + } + iconPos="right" + > +
+ + {status.replace("-", " ")} +
+
+ ))} + Integration + {[...new Set(integrations.map(({ integration }) => integration))].map((integration) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + integrations: prev.integrations.includes(integration) + ? prev.integrations.filter((i) => i !== integration) + : [...prev.integrations, integration] + })); + }} + key={integration} + icon={ + filters.integrations.includes(integration) && ( + + ) + } + iconPos="right" + > +
+ {`${cloudIntegrationMap.get(integration)!.name} + {cloudIntegrationMap.get(integration)!.name} +
+
+ ))} + Environment + {environments.map((env) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + integrations: prev.environmentIds.includes(env.id) + ? prev.environmentIds.filter((i) => i !== env.id) + : [...prev.environmentIds, env.id] + })); + }} + key={env.id} + icon={ + filters.environmentIds.includes(env.id) && ( + + ) + } + iconPos="right" + > + {env.name} + + ))} +
+
+
+ + + + + + + + + + + + + {filteredIntegrations.slice(offset, perPage * page).map((integration) => ( + { + syncIntegration({ + workspaceId, + id: integration.id, + lastUsed: integration.lastUsed as string + }); + }} + onRemoveIntegration={() => onDeleteIntegration(integration)} + integration={integration} + environment={environmentMap.get(integration.envId)} + /> + ))} + +
+
+ Integration + handleSort(IntegrationsOrderBy.App)} + > + + +
+
+
+ Source Path + handleSort(IntegrationsOrderBy.SecretPath)} + > + + +
+
+
+ Source Environment + handleSort(IntegrationsOrderBy.Environment)} + > + + +
+
Destination +
+ Status + handleSort(IntegrationsOrderBy.Status)} + > + + +
+
+
+ {Boolean(filteredIntegrations.length) && ( + + )} + {!isLoading && !filteredIntegrations?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts new file mode 100644 index 000000000..d9567592c --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts @@ -0,0 +1 @@ +export * from "./IntegrationsTable"; From fb6a085bf9e22d78061859dc1c1a161d25e5e4cb Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 3 Dec 2024 15:01:35 -0800 Subject: [PATCH 35/70] chore: remove comment and unused component --- .../ConfiguredIntegrationItem.tsx | 291 ------------------ .../components/IntegrationRow.tsx | 1 - 2 files changed, 292 deletions(-) delete mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx deleted file mode 100644 index 2be3d1f3e..000000000 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import { useRouter } from "next/router"; -import { - faArrowRight, - faCalendarCheck, - faEllipsis, - faRefresh, - faWarning, - faXmark -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; -import { integrationSlugNameMapping } from "public/data/frequentConstants"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; -import { TIntegration } from "@app/hooks/api/types"; - -type IProps = { - integration: TIntegration; - environments: Array<{ name: string; slug: string; id: string }>; - onRemoveIntegration: VoidFunction; - onManualSyncIntegration: VoidFunction; -}; - -export const ConfiguredIntegrationItem = ({ - integration, - environments, - onRemoveIntegration, - onManualSyncIntegration -}: IProps) => { - const router = useRouter(); - - return ( -
router.push(`/integrations/details/${integration.id}`)} - key={`integration-${integration?.id.toString()}`} - > -
-
- -
- {environments.find((e) => e.id === integration.envId)?.name || "-"} -
-
-
- -
- {integration.secretPath} -
-
-
- -
-
- - {/* eslint-disable-next-line no-nested-ternary */} - {integration.metadata?.githubVisibility === "selected" - ? "Syncing to selected repositories in the organization. " - : integration.metadata?.githubVisibility === "private" - ? "Syncing to all private repositories in the organization" - : "Syncing to all public and private repositories in the organization"} -
- ) : undefined - } - label="Integration" - /> -
- {integrationSlugNameMapping[integration.integration]} -
-
- {integration.integration === "octopus-deploy" && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "qovery" && ( -
-
- -
- {integration?.owner || "-"} -
-
-
- -
- {integration?.targetService || "-"} -
-
-
- -
- {integration?.targetEnvironment || "-"} -
-
-
- )} - {!( - integration.integration === "aws-secret-manager" && - integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE - ) && ( -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (["aws-parameter-store", "rundeck"].includes(integration.integration) && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} -
-
- )} - {(integration.integration === "vercel" || - integration.integration === "netlify" || - integration.integration === "railway" || - integration.integration === "gitlab" || - integration.integration === "teamcity" || - (integration.integration === "github" && integration.scope === "github-env")) && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "bitbucket" && ( - <> - {integration.targetServiceId && ( -
- -
- {integration.targetService || integration.targetServiceId} -
-
- )} -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- - )} - {integration.integration === "checkly" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {integration.integration === "circleci" && integration.owner && ( -
- -
- {integration.owner} -
-
- )} - {integration.integration === "terraform-cloud" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {(integration.integration === "checkly" || integration.integration === "github") && ( -
- -
- {integration?.metadata?.secretSuffix || "-"} -
-
- )} -
-
- {integration.isSynced != null && integration.lastUsed != null && ( - - -
- -
Last successful sync
-
-
- {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} -
- {!integration.isSynced && ( - <> -
- -
Fail reason
-
-
{integration.syncMessage}
- - )} -
- } - > -
-
{integration.isSynced ? "Synced" : "Not synced"}
- {!integration.isSynced && } -
- - - )} -
- - { - e.stopPropagation(); - onManualSyncIntegration(); - }} - ariaLabel="sync" - colorSchema="primary" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - - {(isAllowed: boolean) => ( - - { - e.stopPropagation(); - onRemoveIntegration(); - }} - ariaLabel="delete" - isDisabled={!isAllowed} - colorSchema="danger" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - )} - - - - - - - -
-
-
- ); -}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx index 5579c7f76..85bd3aeeb 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx @@ -43,7 +43,6 @@ export const IntegrationRow = ({ if (isSynced === false) { if (syncMessage) try { - // format if json return JSON.stringify(JSON.parse(syncMessage), null, 2); } catch (e) { return syncMessage; From 7a13c155f5799a730ace2d8cc8ef4fd52f70acbe Mon Sep 17 00:00:00 2001 From: = Date: Wed, 4 Dec 2024 15:15:53 +0530 Subject: [PATCH 36/70] feat: updated random pass generator of dynamic secret to use safe characters --- .../ee/services/dynamic-secret/providers/aws-elasticache.ts | 4 ++-- .../ee/services/dynamic-secret/providers/azure-entra-id.ts | 4 ++-- backend/src/ee/services/dynamic-secret/providers/cassandra.ts | 2 +- .../ee/services/dynamic-secret/providers/elastic-search.ts | 4 ++-- .../src/ee/services/dynamic-secret/providers/mongo-atlas.ts | 2 +- backend/src/ee/services/dynamic-secret/providers/mongo-db.ts | 2 +- backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts | 4 ++-- backend/src/ee/services/dynamic-secret/providers/redis.ts | 2 +- backend/src/ee/services/dynamic-secret/providers/snowflake.ts | 2 +- .../src/ee/services/dynamic-secret/providers/sql-database.ts | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index 5fd218f19..534a5c8b2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -127,7 +127,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin }; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -211,7 +211,7 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 9e876f616..17f644601 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -9,7 +9,7 @@ const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/"; const MSFT_LOGIN_URL = "https://login.microsoftonline.com"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -122,7 +122,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return users; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index c030a6813..b2f1f8c35 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -9,7 +9,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 50ab2c694..e91363629 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -95,7 +95,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 95d7e590f..6cb414d10 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -8,7 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index 5a64e0f7b..84dec4d68 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 9647ec6d8..32264ecfa 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -11,7 +11,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; @@ -141,7 +141,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 92ba1d4f9..306b8c59c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -10,7 +10,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index 1b4376f43..3550b146d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -12,7 +12,7 @@ import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; const noop = () => {}; const generatePassword = (size = 48) => { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 835761211..6c9bffd0a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -14,7 +14,7 @@ const generatePassword = (provider: SqlProviders) => { // oracle has limit of 48 password length const size = provider === SqlProviders.Oracle ? 30 : 48; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; From a63191e11d8056332fe1a57da766f7bba75f253b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Dec 2024 22:22:34 +0800 Subject: [PATCH 37/70] misc: use pg queue for audit logs when enabled --- backend/package-lock.json | 74 ++++++----- backend/package.json | 1 + .../ee/services/audit-log/audit-log-queue.ts | 117 ++++++++++++++++-- backend/src/lib/config/env.ts | 4 +- backend/src/main.ts | 9 +- backend/src/queue/queue-service.ts | 64 +++++++++- backend/src/server/routes/index.ts | 3 +- 7 files changed, 228 insertions(+), 44 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 2113d21a6..78a24d436 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -92,6 +92,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", @@ -12259,14 +12260,6 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "node_modules/buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", - "engines": { - "node": ">=4" - } - }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -18185,11 +18178,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18408,15 +18396,13 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pg": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", - "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "dependencies": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.6.2", - "pg-pool": "^3.6.1", - "pg-protocol": "^1.6.0", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -18435,6 +18421,19 @@ } } }, + "node_modules/pg-boss": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.1.5.tgz", + "integrity": "sha512-H87NL6c7N6nTCSCePh16EaSQVSFevNXWdJuzY6PZz4rw+W/nuMKPfI/vYyXS0AdT1g1Q3S3EgeOYOHcB7ZVToQ==", + "dependencies": { + "cron-parser": "^4.9.0", + "pg": "^8.13.0", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pg-cloudflare": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", @@ -18471,17 +18470,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", - "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", - "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-query-stream": { "version": "4.5.3", @@ -18510,9 +18509,9 @@ } }, "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pgpass": { "version": "1.0.5", @@ -20111,6 +20110,20 @@ "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -22130,7 +22143,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/backend/package.json b/backend/package.json index 1aabdde2e..29530cc3a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -200,6 +200,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 83a2fafa6..50813317c 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,6 +1,7 @@ import { RawAxiosRequestHeaders } from "axios"; import { SecretKeyEncoding } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -20,12 +21,13 @@ type TAuditLogQueueServiceFactoryDep = { licenseService: Pick; }; -export type TAuditLogQueueServiceFactory = ReturnType; +export type TAuditLogQueueServiceFactory = Awaited>; // keep this timeout 5s it must be fast because else the queue will take time to finish // audit log is a crowded queue thus needs to be fast export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000; -export const auditLogQueueServiceFactory = ({ + +export const auditLogQueueServiceFactory = async ({ auditLogDAL, queueService, projectDAL, @@ -33,14 +35,113 @@ export const auditLogQueueServiceFactory = ({ auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { const pushToLog = async (data: TCreateAuditLogDTO) => { - await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { - removeOnFail: { - count: 3 - }, - removeOnComplete: true - }); + const appCfg = getConfig(); + if (appCfg.USE_PG_QUEUE) { + await queueService.queuePg(QueueJobs.AuditLog, data, { + retryLimit: 10, + retryBackoff: true + }); + } else { + await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { + removeOnFail: { + count: 3 + }, + removeOnComplete: true + }); + } }; + await queueService.startPg( + QueueJobs.AuditLog, + async ([job]) => { + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; + const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; + + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + project = await projectDAL.findById(projectId as string); + orgId = project.orgId; + } + + const plan = await licenseService.getPlan(orgId); + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } + + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; + + const ttl = ttlInDays * MS_IN_DAY; + + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); + + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; + + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; let { orgId } = job.data; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index a9014a72b..8a4cf07b3 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -178,7 +178,9 @@ const envSchema = z HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), HSM_KEY_LABEL: zpStr(z.string().optional()), - HSM_SLOT: z.coerce.number().optional().default(0) + HSM_SLOT: z.coerce.number().optional().default(0), + + USE_PG_QUEUE: zodStrBool.default("false") }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/main.ts b/backend/src/main.ts index 7f62d6b1e..1d105ebc7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -55,7 +55,14 @@ const run = async () => { } const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL); + + const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); + + if (appCfg.USE_PG_QUEUE) { + logger.info("Initializing PG queue..."); + await queue.initialize(); + } + const keyStore = keyStoreFactory(appCfg.REDIS_URL); const hsmModule = initializeHsmModule(); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 457eebcc1..85322efea 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,5 +1,6 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; import Redis from "ioredis"; +import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; @@ -184,17 +185,31 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string) => { +export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, Queue >; + + const pgBoss = new PgBoss({ + connectionString: dbConnectionUrl, + archiveCompletedAfterSeconds: 30, + maintenanceIntervalSeconds: 30, + deleteAfterSeconds: 30 + }); + + const queueContainerPg = {} as Record; + const workerContainer = {} as Record< QueueName, Worker >; + const initialize = async () => { + return pgBoss.start(); + }; + const start = ( name: T, jobFn: (job: Job, token?: string) => Promise, @@ -215,6 +230,27 @@ export const queueServiceFactory = (redisUrl: string) => { }); }; + const startPg = async ( + jobName: QueueJobs, + jobsFn: (jobs: PgBoss.Job[]) => Promise, + options: WorkOptions & { + workerCount: number; + } + ) => { + if (queueContainerPg[jobName]) { + throw new Error(`${jobName} queue is already initialized`); + } + + await pgBoss.createQueue(jobName); + queueContainerPg[jobName] = true; + + await Promise.all( + Array.from({ length: options.workerCount }).map(() => + pgBoss.work(jobName, options, jobsFn) + ) + ); + }; + const listen = < T extends QueueName, U extends keyof WorkerListener @@ -238,6 +274,18 @@ export const queueServiceFactory = (redisUrl: string) => { await q.add(job, data, opts); }; + const queuePg = async ( + job: TQueueJobTypes[T]["name"], + data: TQueueJobTypes[T]["payload"], + opts?: PgBoss.SendOptions & { jobId?: string } + ) => { + await pgBoss.send({ + name: job, + data, + options: opts + }); + }; + const stopRepeatableJob = async ( name: T, job: TQueueJobTypes[T]["name"], @@ -274,5 +322,17 @@ export const queueServiceFactory = (redisUrl: string) => { await Promise.all(Object.values(workerContainer).map((worker) => worker.close())); }; - return { start, listen, queue, shutdown, stopRepeatableJob, stopRepeatableJobByJobId, clearQueue, stopJobById }; + return { + initialize, + start, + listen, + queue, + shutdown, + stopRepeatableJob, + stopRepeatableJobByJobId, + clearQueue, + stopJobById, + startPg, + queuePg + }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b9f46627b..4f07579bd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -394,13 +394,14 @@ export const registerRoutes = async ( permissionService }); - const auditLogQueue = auditLogQueueServiceFactory({ + const auditLogQueue = await auditLogQueueServiceFactory({ auditLogDAL, queueService, projectDAL, licenseService, auditLogStreamDAL }); + const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const auditLogStreamService = auditLogStreamServiceFactory({ licenseService, From 11c96245a709c4be922b186b745ac91bd7cade44 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Dec 2024 22:27:07 +0800 Subject: [PATCH 38/70] misc: added error listener --- backend/src/queue/queue-service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 85322efea..123fc7809 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -8,6 +8,7 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { logger } from "@app/lib/logger"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -207,7 +208,11 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = >; const initialize = async () => { - return pgBoss.start(); + await pgBoss.start(); + + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); }; const start = ( From a750f48922e8fb30c860c419f4a4a18e340d46b5 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Dec 2024 22:49:28 +0800 Subject: [PATCH 39/70] misc: finalized structure --- backend/e2e-test/mocks/queue.ts | 3 + .../ee/services/audit-log/audit-log-queue.ts | 167 +++++++++--------- backend/src/main.ts | 6 +- backend/src/queue/queue-service.ts | 17 +- 4 files changed, 100 insertions(+), 93 deletions(-) diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index c694979db..0028381bd 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -10,12 +10,15 @@ export const mockQueue = (): TQueueServiceFactory => { queue: async (name, jobData) => { job[name] = jobData; }, + queuePg: async () => {}, + initialize: async () => {}, shutdown: async () => undefined, stopRepeatableJob: async () => true, start: (name, jobFn) => { queues[name] = jobFn; workers[name] = jobFn; }, + startPg: async () => {}, listen: (name, event) => { events[name] = event; }, diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 50813317c..a9e322962 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -34,8 +34,9 @@ export const auditLogQueueServiceFactory = async ({ licenseService, auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { + const appCfg = getConfig(); + const pushToLog = async (data: TCreateAuditLogDTO) => { - const appCfg = getConfig(); if (appCfg.USE_PG_QUEUE) { await queueService.queuePg(QueueJobs.AuditLog, data, { retryLimit: 10, @@ -51,96 +52,98 @@ export const auditLogQueueServiceFactory = async ({ } }; - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; + if (appCfg.USE_PG_QUEUE) { + await queueService.startPg( + QueueJobs.AuditLog, + async ([job]) => { + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; + const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + project = await projectDAL.findById(projectId as string); + orgId = project.orgId; + } - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } + const plan = await licenseService.getPlan(orgId); + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; - const ttl = ttlInDays * MS_IN_DAY; + const ttl = ttlInDays * MS_IN_DAY; - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - infisicalSymmetricDecrypt({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) }); - - return request.post(url, auditLog, { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - }); - } - ) - ); - }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 - } - ); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + } queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; diff --git a/backend/src/main.ts b/backend/src/main.ts index 1d105ebc7..8e3602974 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -57,11 +57,7 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); - - if (appCfg.USE_PG_QUEUE) { - logger.info("Initializing PG queue..."); - await queue.initialize(); - } + await queue.initialize(); const keyStore = keyStoreFactory(appCfg.REDIS_URL); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 123fc7809..3205c8c94 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -8,6 +8,7 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TFailedIntegrationSyncEmailsPayload, @@ -195,8 +196,8 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = const pgBoss = new PgBoss({ connectionString: dbConnectionUrl, - archiveCompletedAfterSeconds: 30, - maintenanceIntervalSeconds: 30, + archiveCompletedAfterSeconds: 60, + archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried deleteAfterSeconds: 30 }); @@ -208,11 +209,15 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = >; const initialize = async () => { - await pgBoss.start(); + const appCfg = getConfig(); + if (appCfg.USE_PG_QUEUE) { + logger.info("Initializing PG queue..."); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); + } }; const start = ( From 32d6826ade15bf1f17ef07a1173014291ab01d24 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Dec 2024 22:52:30 +0800 Subject: [PATCH 40/70] fix: resolve e2e --- backend/e2e-test/vitest-environment-knex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 866b0f45f..9ca485236 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -53,7 +53,7 @@ export default { extension: "ts" }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(cfg.REDIS_URL); + const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI); const keyStore = keyStoreFactory(cfg.REDIS_URL); const hsmModule = initializeHsmModule(); From 0842901d4f24894b5599fd59fc00d592950c091d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Dec 2024 23:21:37 +0800 Subject: [PATCH 41/70] misc: always initialize pg-boss --- .../ee/services/audit-log/audit-log-queue.ts | 178 +++++++++--------- backend/src/queue/queue-service.ts | 13 +- 2 files changed, 92 insertions(+), 99 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index a9e322962..a1c35bc40 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -52,98 +52,96 @@ export const auditLogQueueServiceFactory = async ({ } }; - if (appCfg.USE_PG_QUEUE) { - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; + await queueService.startPg( + QueueJobs.AuditLog, + async ([job]) => { + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; + const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } - - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } - - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; - - const ttl = ttlInDays * MS_IN_DAY; - - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); - - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - infisicalSymmetricDecrypt({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - return request.post(url, auditLog, { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - }); - } - ) - ); - }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + project = await projectDAL.findById(projectId as string); + orgId = project.orgId; } - ); - } + + const plan = await licenseService.getPlan(orgId); + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } + + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; + + const ttl = ttlInDays * MS_IN_DAY; + + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); + + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; + + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 3205c8c94..f18562513 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -8,7 +8,6 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; -import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TFailedIntegrationSyncEmailsPayload, @@ -209,15 +208,11 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = >; const initialize = async () => { - const appCfg = getConfig(); - if (appCfg.USE_PG_QUEUE) { - logger.info("Initializing PG queue..."); - await pgBoss.start(); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); - } + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); }; const start = ( From 3c633129444bc11fdaa8dfa74d1ba5a05b16e269 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 4 Dec 2024 21:26:23 +0530 Subject: [PATCH 42/70] feat: added identity id condition in identity permission of a project --- ...ty-project-additional-privilege-service.ts | 31 +++- .../services/permission/project-permission.ts | 56 ++++-- .../identity-project-service.ts | 19 +- frontend/package-lock.json | 2 +- .../context/ProjectPermissionContext/types.ts | 12 +- frontend/src/reactQuery.tsx | 2 +- .../IdentityDetailPage.tsx | 5 +- ...rojectAdditionalPrivilegeModifySection.tsx | 80 +++++--- ...ntityProjectAdditionalPrivilegeSection.tsx | 13 +- ...rojectAdditionalPrivilegeModifySection.tsx | 77 ++++++-- .../components/IdentityTab/IdentityTab.tsx | 5 +- .../ProjectRoleModifySection.utils.tsx | 23 ++- .../RolePermissionsSection.tsx | 84 ++++++--- .../components/GeneralPermissionPolicies.tsx | 2 +- ...IdentityManagementPermissionConditions.tsx | 171 ++++++++++++++++++ .../components/PermissionConditionHelpers.tsx | 1 + 16 files changed, 481 insertions(+), 102 deletions(-) create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 4811eb52a..127de1383 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; @@ -69,7 +69,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -146,7 +150,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, @@ -241,7 +249,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -294,7 +306,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -333,7 +348,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ projectMembershipId: identityProjectMembership.id diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c6e574fb1..f6d7f715f 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -82,6 +82,10 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type IdentityManagementSubjectFields = { + identityId: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionActions, @@ -121,7 +125,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] - | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ + ProjectPermissionActions, + ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) + ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] @@ -213,6 +220,21 @@ const SecretConditionV2Schema = z }) .partial(); +const IdentityManagementConditionSchema = z + .object({ + identityId: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -262,12 +284,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.ServiceTokens).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -373,6 +389,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); @@ -417,6 +439,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ), + conditions: IdentityManagementConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); @@ -697,26 +729,26 @@ export const buildServiceTokenProjectPermission = ( [ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretFolders].forEach( (subject) => { if (canWrite) { - // TODO: @Akhi - // @ts-expect-error type can(ProjectPermissionActions.Edit, subject, { + // TODO: @Akhi + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); - // @ts-expect-error type can(ProjectPermissionActions.Create, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); - // @ts-expect-error type can(ProjectPermissionActions.Delete, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); } if (canRead) { - // @ts-expect-error type can(ProjectPermissionActions.Read, subject, { + // @ts-expect-error type secretPath: { $glob: secretPath }, environment }); diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 7f9cf920e..69364a3cf 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import ms from "ms"; import { ProjectMembershipRole } from "@app/db/schemas"; @@ -161,7 +161,10 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId }); if (!projectIdentity) @@ -253,7 +256,11 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -317,7 +324,11 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId }); if (!identityMembership) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 91b8d4bc3..64380ae9f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -89,7 +89,7 @@ "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", - "react-select": "^5.8.3", + "react-select": "^5.8.1", "react-table": "^7.8.0", "react-toastify": "^9.1.3", "sanitize-html": "^2.12.1", diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 8f10d5f21..673b2b41a 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -33,6 +33,10 @@ export enum PermissionConditionOperators { $GLOB = "$glob" } +export type IdentityManagementSubjectFields = { + identityId: string; +}; + export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", [PermissionConditionOperators.$IN]: "contains", @@ -151,7 +155,13 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] - | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ + ProjectPermissionActions, + ( + | ProjectPermissionSub.Identity + | (ForcedSubject & IdentityManagementSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] diff --git a/frontend/src/reactQuery.tsx b/frontend/src/reactQuery.tsx index c897c6e02..1b6e69b36 100644 --- a/frontend/src/reactQuery.tsx +++ b/frontend/src/reactQuery.tsx @@ -92,7 +92,7 @@ export const queryClient = new QueryClient({ >
{serverResponse.details?.map((el, index) => { - const hasConditions = Object.keys(el.conditions || {}).length; + const hasConditions = Boolean(Object.keys(el.conditions || {}).length); return (
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index f69cd1478..586a1db7a 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -17,25 +17,24 @@ import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, FormControl, FormLabel, Input, - Modal, - ModalContent, - ModalTrigger, Popover, PopoverContent, PopoverTrigger, Tag, - Tooltip -} from "@app/components/v2"; + Tooltip} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useCreateIdentityProjectAdditionalPrivilege, useGetIdentityProjectPrivilegeDetails, @@ -43,14 +42,13 @@ import { } from "@app/hooks/api"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types"; import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule"; import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState"; import { formRolePermission2API, + isConditionalSubjects, PROJECT_PERMISSION_OBJECT, projectRoleFormSchema, - rolePermission2Form -} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; + rolePermission2Form} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection"; type Props = { @@ -88,7 +86,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ }: Props) => { const isCreate = !privilegeId; const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: privilegeDetails, isLoading } = useGetIdentityProjectPrivilegeDetails({ identityId, @@ -194,6 +191,30 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ } } + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + return (
Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + +
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index 975ddb5c7..db900b1b2 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format, formatDistance } from "date-fns"; @@ -83,7 +84,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={permission.cannot( ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { + identityId + }) )} /> @@ -103,7 +106,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe @@ -192,7 +197,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx index e5a6adf4d..4118429b2 100644 --- a/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -17,12 +17,13 @@ import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, FormControl, FormLabel, Input, - Modal, - ModalContent, - ModalTrigger, Popover, PopoverContent, PopoverTrigger, @@ -35,7 +36,6 @@ import { useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useCreateProjectUserAdditionalPrivilege, useGetProjectUserPrivilegeDetails, @@ -43,14 +43,13 @@ import { } from "@app/hooks/api"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule"; import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState"; import { formRolePermission2API, + isConditionalSubjects, PROJECT_PERMISSION_OBJECT, projectRoleFormSchema, - rolePermission2Form -} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; + rolePermission2Form} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection"; type Props = { @@ -88,7 +87,6 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ }: Props) => { const isCreate = !privilegeId; const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: privilegeDetails, isLoading } = useGetProjectUserPrivilegeDetails( privilegeId || "" @@ -167,6 +165,30 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ } }; + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + const privilegeTemporaryAccess = form.watch("temporaryAccess"); const isTemporary = privilegeTemporaryAccess?.isTemporary; const isExpired = @@ -229,24 +251,39 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ > Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + +
diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index defd32863..362c15656 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { useRouter } from "next/router"; +import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp, @@ -349,7 +350,9 @@ export const IdentityTab = withProjectPermission( {(isAllowed) => ( { const formConditions: z.infer = []; @@ -483,8 +490,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove members", value: "delete" } ] }, - [ProjectPermissionSub.Groups]: { - title: "Group Management", + [ProjectPermissionSub.Identity]: { + title: "Machine Identity Management", actions: [ { label: "Read", value: "read" }, { label: "Create", value: "create" }, @@ -492,8 +499,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove", value: "delete" } ] }, - [ProjectPermissionSub.Identity]: { - title: "Machine Identity Management", + [ProjectPermissionSub.Groups]: { + title: "Group Management", actions: [ { label: "Read", value: "read" }, { label: "Create", value: "create" }, @@ -527,7 +534,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { ] }, [ProjectPermissionSub.Environments]: { - title: "Environments", + title: "Environment Management", actions: [ { label: "Read", value: "read" }, { label: "Create", value: "create" }, diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 1e00306be..16397ac24 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -5,14 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Alert, Button, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { GeneralPermissionConditions } from "./components/GeneralPermissionConditions"; import { GeneralPermissionPolicies } from "./components/GeneralPermissionPolicies"; -import { NewPermissionRule } from "./components/NewPermissionRule"; +import { IdentityManagementPermissionConditions } from "./components/IdentityManagementPermissionConditions"; import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; import { PermissionEmptyState } from "./PermissionEmptyState"; import { @@ -37,6 +42,10 @@ export const renderConditionalComponents = ( return ; if (isConditionalSubjects(subject)) { + if (subject === ProjectPermissionSub.Identity) { + return ; + } + return ; } @@ -45,7 +54,6 @@ export const renderConditionalComponents = ( export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const { currentWorkspace } = useWorkspace(); - const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectId = currentWorkspace?.id || ""; const { data: role, isLoading } = useGetProjectRoleBySlug( currentWorkspace?.id ?? "", @@ -83,6 +91,30 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? ""); + const onNewPolicy = (selectedSubject: ProjectPermissionSub) => { + const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`); + if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [...rootPolicyValue, ...[]], + { shouldDirty: true, shouldTouch: true } + ); + } else { + form.setValue( + `permissions.${selectedSubject}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error akhilmhdh: this is because of ts collision with both + [{}], + { + shouldDirty: true, + shouldTouch: true + } + ); + } + }; + return ( { > Save - handlePopUpToggle("createPolicy", isOpen)} - > - + + - - - handlePopUpToggle("createPolicy")} /> - - + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[ + a as keyof typeof PROJECT_PERMISSION_OBJECT + ].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + +
)}
-
{!isLoading && } {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx index 9d6e699cb..15d819fc8 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies.tsx @@ -157,7 +157,7 @@ export const GeneralPermissionPolicies = { - items.insert(rootIndex, [ + items.insert(rootIndex + 1, [ { read: false, edit: false, create: false, delete: false } as any ]); }} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx new file mode 100644 index 000000000..9409ce46d --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx @@ -0,0 +1,171 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { + PermissionConditionOperators, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; + +import { TFormSchema } from "../ProjectRoleModifySection.utils"; +import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const IdentityManagementPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + formState: { errors } + } = useFormContext(); + const permissionSubject = ProjectPermissionSub.Identity; + const items = useFieldArray({ + control, + name: `permissions.${permissionSubject}.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ When this policy should apply (always if no conditions are added). +

+
+ {items.fields.map((el, index) => { + const condition = + (watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }) || {}; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message} +
+ )} +
{}
+
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx index 21fad117a..212f38e92 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx @@ -21,6 +21,7 @@ export const renderOperatorSelectItems = (type: string) => { if (type === "secretTags") { return Contains; } + return ( <> Equal From 48cb5f6e9bbd42765bb88d204cf9a8c3fde3bea3 Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Tue, 3 Dec 2024 23:25:27 +0000 Subject: [PATCH 43/70] feat(notifications): add copyable request IDs --- .../notifications/Notifications.tsx | 38 ++++++++++++- .../components/v2/CopyButton/CopyButton.tsx | 57 +++++++++++++++++++ .../src/components/v2/CopyButton/index.tsx | 2 + .../src/components/v2/Tooltip/Tooltip.tsx | 6 +- frontend/src/hooks/api/dashboard/queries.tsx | 42 ++++++++++++-- frontend/src/hooks/api/secrets/queries.tsx | 31 ++++++++-- frontend/src/reactQuery.tsx | 41 ++++++++----- 7 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/v2/CopyButton/CopyButton.tsx create mode 100644 frontend/src/components/v2/CopyButton/index.tsx diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 23b4eebaa..41e8194ea 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -1,18 +1,54 @@ import { ReactNode } from "react"; import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify"; +import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { twMerge } from "tailwind-merge"; + +import { CopyButton } from "../v2/CopyButton"; export type TNotification = { title?: string; text: ReactNode; children?: ReactNode; + cta?: ReactNode; + copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[]; }; -export const NotificationContent = ({ title, text, children }: TNotification) => { +export const NotificationContent = ({ title, text, children, cta, copyActions }: TNotification) => { return (
{title &&
{title}
}
{text}
{children &&
{children}
} + {(cta || copyActions) && ( +
+ {cta} + + {copyActions && ( +
+ {copyActions.map((action) => ( +
+ {action.label && ( + {action.label} + )} + +
+ ))} +
+ )} +
+ )}
); }; diff --git a/frontend/src/components/v2/CopyButton/CopyButton.tsx b/frontend/src/components/v2/CopyButton/CopyButton.tsx new file mode 100644 index 000000000..63db5901e --- /dev/null +++ b/frontend/src/components/v2/CopyButton/CopyButton.tsx @@ -0,0 +1,57 @@ +import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { useTimedReset } from "@app/hooks"; + +import { IconButton } from "../IconButton"; +import { Tooltip } from "../Tooltip"; + +export type CopyButtonProps = { + value: string; + size?: "xs" | "sm" | "md" | "lg"; + variant?: "solid" | "outline" | "plain" | "star" | "outline_bg"; + color?: string; + name?: string; + icon?: IconDefinition; +}; + +export const CopyButton = ({ + value, + size = "sm", + variant = "solid", + color, + name, + icon = faCopy +}: CopyButtonProps) => { + const tooltipText = name ? `Copy ${name}` : "Copy to clipboard"; + + const [copyText, isCopying, setCopyText] = useTimedReset({ + initialState: tooltipText + }); + + async function handleCopyText() { + setCopyText("Copied"); + navigator.clipboard.writeText(value); + } + + return ( +
+ + { + handleCopyText(); + }} + > + + + +
+ ); +}; + +CopyButton.displayName = "CopyButton"; diff --git a/frontend/src/components/v2/CopyButton/index.tsx b/frontend/src/components/v2/CopyButton/index.tsx new file mode 100644 index 000000000..9a7bc5991 --- /dev/null +++ b/frontend/src/components/v2/CopyButton/index.tsx @@ -0,0 +1,2 @@ +export type { CopyButtonProps } from "./CopyButton"; +export { CopyButton } from "./CopyButton"; diff --git a/frontend/src/components/v2/Tooltip/Tooltip.tsx b/frontend/src/components/v2/Tooltip/Tooltip.tsx index e02b9fc38..abfebc5b4 100644 --- a/frontend/src/components/v2/Tooltip/Tooltip.tsx +++ b/frontend/src/components/v2/Tooltip/Tooltip.tsx @@ -13,6 +13,7 @@ export type TooltipProps = Omit // just render children if tooltip content is empty @@ -43,7 +45,7 @@ export const Tooltip = ({ sideOffset={5} {...props} className={twMerge( - `z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md + `z-50 max-w-[15rem] select-none border border-mineshaft-600 bg-mineshaft-800 font-light text-bunker-200 shadow-md data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade @@ -51,6 +53,8 @@ export const Tooltip = ({ `, isDisabled && "!hidden", center && "text-center", + size === "sm" && "rounded-sm py-1 px-2 text-xs", + size === "md" && "rounded-md py-2 px-4 text-sm", className )} > diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index adff8bb0e..3aa599716 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -177,11 +177,21 @@ export const useGetProjectSecretsOverview = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secret details", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -270,11 +280,21 @@ export const useGetProjectSecretsDetails = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secret details", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -355,11 +375,21 @@ export const useGetProjectSecretsQuickSearch = ( }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secrets deep search", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index b3b3a4164..a803ff50b 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -117,11 +117,21 @@ export const useGetProjectSecrets = ({ queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }), onError: (error) => { if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as { message: string }; + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; createNotification({ title: "Error fetching secrets", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } }, @@ -148,15 +158,24 @@ export const useGetProjectSecretsAllEnv = ({ enabled: Boolean(workspaceId && environment), onError: (error: unknown) => { if (axios.isAxiosError(error) && !isErrorHandled) { - const serverResponse = error.response?.data as { message: string }; - if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) { + const { message, requestId } = error.response?.data as { + message: string; + requestId: string; + }; + if (message !== ERROR_NOT_ALLOWED_READ_SECRETS) { createNotification({ title: "Error fetching secrets", type: "error", - text: serverResponse.message + text: message, + copyActions: [ + { + value: requestId, + name: "Request ID", + label: `Request ID: ${requestId}` + } + ] }); } - setIsErrorHandled.on(); } }, diff --git a/frontend/src/reactQuery.tsx b/frontend/src/reactQuery.tsx index c897c6e02..c5e3d0e3b 100644 --- a/frontend/src/reactQuery.tsx +++ b/frontend/src/reactQuery.tsx @@ -32,13 +32,8 @@ export const queryClient = new QueryClient({ { title: "Validation Error", type: "error", - text: ( -
-

Please check the input and try again.

-

Request ID: {serverResponse.requestId}

-
- ), - children: ( + text: "Please check the input and try again.", + cta: (
- ) : undefined + ) : undefined, + copyActions: [ + { + value: serverResponse.requestId, + name: "Request ID", + label: `Request ID: ${serverResponse.requestId}` + } + ] }, { closeOnClick: false } ); @@ -174,7 +182,14 @@ export const queryClient = new QueryClient({ createNotification({ title: "Bad Request", type: "error", - text: `${serverResponse.message} [requestId=${serverResponse.requestId}]` + text: `${serverResponse.message}.`, + copyActions: [ + { + value: serverResponse.requestId, + name: "Request ID", + label: `Request ID: ${serverResponse.requestId}` + } + ] }); } } From 2b44e32ac1a791b78c005baad1e9dbc5fa3e66ba Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 5 Dec 2024 01:13:36 +0400 Subject: [PATCH 44/70] docs(java-sdk): updated for v3.0.0 --- docs/mint.json | 2 +- docs/sdks/languages/java.mdx | 6 +++++- docs/sdks/overview.mdx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/mint.json b/docs/mint.json index 7df2e1062..6e5855875 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -472,9 +472,9 @@ "pages": [ "sdks/languages/node", "sdks/languages/python", + "sdks/languages/java", "sdks/languages/go", "sdks/languages/ruby", - "sdks/languages/java", "sdks/languages/csharp" ] }, diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 3a712322e..dc07b146d 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -1,9 +1,12 @@ --- title: "Infisical Java SDK" sidebarTitle: "Java" +url: "https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-nodejs-sdk" icon: "java" --- +{ +/* If you're working with Java, the official [Infisical Java SDK](https://github.com/Infisical/sdk/tree/main/languages/java) package is the easiest way to fetch and work with secrets for your application. - [Maven Package](https://github.com/Infisical/sdk/packages/2019741) @@ -568,4 +571,5 @@ String decryptedString = client.decryptSymmetric(decryptOptions); #### Returns (string) -`Plaintext` (string): The decrypted plaintext. \ No newline at end of file +`Plaintext` (string): The decrypted plaintext. +*/} \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 11d34bb38..a58be0688 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -16,7 +16,7 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Python application on demand - + Manage secrets for your Java application on demand From 8b2a866994cab9a85e2253012b77f8d32118cb36 Mon Sep 17 00:00:00 2001 From: McPizza Date: Wed, 4 Dec 2024 23:32:55 +0000 Subject: [PATCH 45/70] fix nits --- .../components/notifications/Notifications.tsx | 16 +++++++++++----- .../src/components/v2/CopyButton/CopyButton.tsx | 6 ++---- frontend/src/reactQuery.tsx | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 41e8194ea..fdc35b9af 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -9,24 +9,30 @@ export type TNotification = { title?: string; text: ReactNode; children?: ReactNode; - cta?: ReactNode; + callToAction?: ReactNode; copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[]; }; -export const NotificationContent = ({ title, text, children, cta, copyActions }: TNotification) => { +export const NotificationContent = ({ + title, + text, + children, + callToAction, + copyActions +}: TNotification) => { return (
{title &&
{title}
}
{text}
{children &&
{children}
} - {(cta || copyActions) && ( + {(callToAction || copyActions) && (
- {cta} + {callToAction} {copyActions && (
diff --git a/frontend/src/components/v2/CopyButton/CopyButton.tsx b/frontend/src/components/v2/CopyButton/CopyButton.tsx index 63db5901e..ff1161ca9 100644 --- a/frontend/src/components/v2/CopyButton/CopyButton.tsx +++ b/frontend/src/components/v2/CopyButton/CopyButton.tsx @@ -24,10 +24,8 @@ export const CopyButton = ({ name, icon = faCopy }: CopyButtonProps) => { - const tooltipText = name ? `Copy ${name}` : "Copy to clipboard"; - const [copyText, isCopying, setCopyText] = useTimedReset({ - initialState: tooltipText + initialState: name ? `Copy ${name}` : "Copy to clipboard" }); async function handleCopyText() { @@ -39,7 +37,7 @@ export const CopyButton = ({
{isLoading && Array.from({ length: 12 }).map((_, index) => ( ))} - {!isLoading && - sortedCloudIntegrations?.map((cloudIntegration) => ( + + {!isLoading && filteredIntegrations.length ? ( + filteredIntegrations.map((cloudIntegration) => (
null} role="button" @@ -146,7 +177,14 @@ export const CloudIntegrationSection = ({
)}
- ))} + )) + ) : ( + + )}
{isEmpty && (
From 0a1242db75d16a9ea3dbf3e699d707763622b603 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 5 Dec 2024 15:52:17 +0800 Subject: [PATCH 54/70] misc: added pg queue init flag --- .../ee/services/audit-log/audit-log-queue.ts | 166 +++++++++--------- backend/src/lib/config/env.ts | 3 +- backend/src/queue/queue-service.ts | 13 +- 3 files changed, 95 insertions(+), 87 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index a1c35bc40..e312c3886 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -37,7 +37,7 @@ export const auditLogQueueServiceFactory = async ({ const appCfg = getConfig(); const pushToLog = async (data: TCreateAuditLogDTO) => { - if (appCfg.USE_PG_QUEUE) { + if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) { await queueService.queuePg(QueueJobs.AuditLog, data, { retryLimit: 10, retryBackoff: true @@ -52,96 +52,98 @@ export const auditLogQueueServiceFactory = async ({ } }; - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; + if (appCfg.SHOULD_INIT_PG_QUEUE) { + await queueService.startPg( + QueueJobs.AuditLog, + async ([job]) => { + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; + const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + project = await projectDAL.findById(projectId as string); + orgId = project.orgId; + } - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } + const plan = await licenseService.getPlan(orgId); + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; - const ttl = ttlInDays * MS_IN_DAY; + const ttl = ttlInDays * MS_IN_DAY; - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - infisicalSymmetricDecrypt({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) }); - - return request.post(url, auditLog, { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - }); - } - ) - ); - }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 - } - ); + } + ) + ); + }, + { + batchSize: 1, + workerCount: 30, + pollingIntervalSeconds: 0.5 + } + ); + } queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8a4cf07b3..66c5f3d98 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -180,7 +180,8 @@ const envSchema = z HSM_KEY_LABEL: zpStr(z.string().optional()), HSM_SLOT: z.coerce.number().optional().default(0), - USE_PG_QUEUE: zodStrBool.default("false") + USE_PG_QUEUE: zodStrBool.default("false"), + SHOULD_INIT_PG_QUEUE: zodStrBool.default("false") }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f18562513..8479a249c 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -8,6 +8,7 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TFailedIntegrationSyncEmailsPayload, @@ -208,11 +209,15 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = >; const initialize = async () => { - await pgBoss.start(); + const appCfg = getConfig(); + if (appCfg.SHOULD_INIT_PG_QUEUE) { + logger.info("Initializing pg-queue..."); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); + } }; const start = ( From bcd778457d2ce8756210417719f9d3a1b698126c Mon Sep 17 00:00:00 2001 From: = Date: Thu, 5 Dec 2024 14:04:59 +0530 Subject: [PATCH 55/70] feat: added identity id in privilege section v2 as well --- ...project-additional-privilege-v2-service.ts | 32 +++++++++++++++---- .../identity-project-service.ts | 7 +++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 26a694a4a..b1c40a879 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; import ms from "ms"; @@ -62,7 +62,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, @@ -139,7 +142,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const { permission: targetIdentityPermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -216,7 +222,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityProjectMembership.identityId, @@ -258,7 +267,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); return { ...identityPrivilege, @@ -289,7 +301,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -321,7 +336,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) + ); const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find( { diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 69364a3cf..a2524a0b9 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -61,7 +61,12 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Identity, { + identityId + }) + ); const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId }); if (existingIdentity) From 882f6b22f52900eca0c153fdac305340b84ec948 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 5 Dec 2024 14:08:08 +0530 Subject: [PATCH 56/70] feat: updated frontend for review changes --- ...rojectAdditionalPrivilegeModifySection.tsx | 41 +- .../IdentityRoleDetailsSection.tsx | 9 +- .../IdentityRoleForm/IdentityRbacSection.tsx | 354 ------------------ .../IdentityRoleForm/IdentityRoleForm.tsx | 40 -- .../components/IdentityRoleForm/index.tsx | 1 - .../ProjectRoleModifySection.utils.tsx | 2 +- 6 files changed, 31 insertions(+), 416 deletions(-) delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index 586a1db7a..db1888a22 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -1,4 +1,5 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; import { faCaretDown, faChevronLeft, @@ -28,7 +29,8 @@ import { PopoverContent, PopoverTrigger, Tag, - Tooltip} from "@app/components/v2"; + Tooltip +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -48,7 +50,8 @@ import { isConditionalSubjects, PROJECT_PERMISSION_OBJECT, projectRoleFormSchema, - rolePermission2Form} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; + rolePermission2Form +} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection"; type Props = { @@ -95,7 +98,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId }) ); const form = useForm({ @@ -275,13 +278,13 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ ].title.toLowerCase() ) ) - .map((subject) => ( + .map((permissionSubject) => ( onNewPolicy(subject as ProjectPermissionSub)} + onClick={() => onNewPolicy(permissionSubject as ProjectPermissionSub)} > - {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + {PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title} ))} @@ -412,17 +415,19 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
Policies
{(isCreate || !isLoading) && } - {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - - {renderConditionalComponents(subject, isDisabled)} - - ))} + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map( + (permissionSubject) => ( + + {renderConditionalComponents(permissionSubject, isDisabled)} + + ) + )}
diff --git a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx index 300114228..1fa98628e 100644 --- a/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx +++ b/frontend/src/views/Project/IdentityDetailsPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format, formatDistance } from "date-fns"; @@ -93,7 +94,9 @@ export const IdentityRoleDetailsSection = ({

Project Roles

@@ -175,7 +178,9 @@ export const IdentityRoleDetailsSection = ({
diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx deleted file mode 100644 index 47ce3f94b..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* eslint-disable no-nested-ternary */ -import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { format, formatDistance } from "date-fns"; -import ms from "ms"; -import { twMerge } from "tailwind-merge"; -import { z } from "zod"; - -import { TtlFormLabel } from "@app/components/features"; -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - FormControl, - IconButton, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Select, - SelectItem, - Spinner, - Tag, - Tooltip -} from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useProjectPermission, - useSubscription, - useWorkspace -} from "@app/context"; -import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; -import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; - -const roleFormSchema = z.object({ - roles: z - .object({ - slug: z.string(), - temporaryAccess: z.discriminatedUnion("isTemporary", [ - z.object({ - isTemporary: z.literal(true), - temporaryRange: z.string().min(1), - temporaryAccessStartTime: z.string().datetime(), - temporaryAccessEndTime: z.string().datetime().nullable().optional() - }), - z.object({ - isTemporary: z.literal(false) - }) - ]) - }) - .array() -}); -type TRoleForm = z.infer; - -type Props = { - identityProjectMember: IdentityMembership; - onOpenUpgradeModal: (title: string) => void; -}; -export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const { permission } = useProjectPermission(); - const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, - ProjectPermissionSub.Identity - ); - - const roleForm = useForm({ - resolver: zodResolver(roleFormSchema), - values: { - roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ - slug: customRoleSlug || role, - temporaryAccess: dto.isTemporary - ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } - : { - isTemporary: dto.isTemporary - } - })) - } - }); - const selectedRoleList = useFieldArray({ - name: "roles", - control: roleForm.control - }); - - const formRoleField = roleForm.watch("roles"); - - const updateMembershipRole = useUpdateIdentityWorkspaceRole(); - - const handleRoleUpdate = async (data: TRoleForm) => { - if (updateMembershipRole.isLoading) return; - - const sanitizedRoles = data.roles.map((el) => { - const { isTemporary } = el.temporaryAccess; - if (!isTemporary) { - return { role: el.slug, isTemporary: false as const }; - } - return { - role: el.slug, - isTemporary: true as const, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: el.temporaryAccess.temporaryRange, - temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime - }; - }); - - const hasCustomRoleSelected = sanitizedRoles.some( - (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) - ); - - if (hasCustomRoleSelected && subscription && !subscription?.rbac) { - onOpenUpgradeModal( - "You can assign custom roles to members if you upgrade your Infisical plan." - ); - return; - } - - try { - await updateMembershipRole.mutateAsync({ - workspaceId, - identityId: identityProjectMember.identity.id, - roles: sanitizedRoles - }); - createNotification({ text: "Successfully updated roles", type: "success" }); - roleForm.reset(undefined, { keepValues: true }); - } catch (err) { - createNotification({ text: "Failed to update role", type: "error" }); - } - }; - - if (isRolesLoading) - return ( -
- -
- ); - - return ( -
-
Roles
-

Select one of the pre-defined or custom roles.

-
-
-
- {selectedRoleList.fields.map(({ id }, index) => { - const { temporaryAccess } = formRoleField[index]; - const isTemporary = temporaryAccess?.isTemporary; - const isExpired = - temporaryAccess.isTemporary && - new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); - - return ( -
- ( - - )} - /> - - -
- - - -
-
- -
-
- Configure timed access -
- {isExpired && Expired} - ( - } - isError={Boolean(error?.message)} - errorText={error?.message} - > - - - )} - /> -
- - {temporaryAccess.isTemporary && ( - - )} -
-
-
-
- { - if (selectedRoleList.fields.length > 1) { - selectedRoleList.remove(index); - } - }} - > - - -
- ); - })} -
-
- - {(isAllowed) => ( - - )} - - -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx deleted file mode 100644 index 3640984cd..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Link from "next/link"; - -import { Alert, AlertDescription } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; - -import { IdentityRbacSection } from "./IdentityRbacSection"; - -type Props = { - identityProjectMember: IdentityMembership; - onOpenUpgradeModal: (title: string) => void; -}; -export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { - const { currentWorkspace } = useWorkspace(); - - return ( -
- - - - - - Click here to access them now - - - - -
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx deleted file mode 100644 index f59675cb3..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentityRoleForm } from "./IdentityRoleForm"; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx index fd168d1d1..e7f4fc91a 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx @@ -494,7 +494,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { title: "Machine Identity Management", actions: [ { label: "Read", value: "read" }, - { label: "Create", value: "create" }, + { label: "Add", value: "create" }, { label: "Modify", value: "edit" }, { label: "Remove", value: "delete" } ] From fcbedfaf1b5db08e7ff38788d9cb62d91ffbe09b Mon Sep 17 00:00:00 2001 From: = Date: Thu, 5 Dec 2024 14:20:05 +0530 Subject: [PATCH 57/70] feat: updated changes by review feedback --- .../controllers/infisicalsecret_helper.go | 33 ++++++++----------- k8-operator/main.go | 2 +- k8-operator/packages/model/model.go | 7 +++- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index 042d19edf..cc9b5c68e 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -227,11 +227,6 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub return model.ServiceAccountDetails{AccessKey: string(accessKeyFromSecret), PrivateKey: string(privateKeyFromSecret), PublicKey: string(publicKeyFromSecret)}, nil } -type TemplateSecret struct { - Value string `json:"value"` - SecretPath string `json:"secretPath"` -} - func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { plainProcessedSecrets := make(map[string][]byte) secretType := infisicalSecret.Spec.ManagedSecretReference.SecretType @@ -244,26 +239,26 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context } if managedTemplateData != nil { - secretKeyValue := make(map[string]TemplateSecret) + secretKeyValue := make(map[string]model.SecretTemplateOptions) for _, secret := range secretsFromAPI { - secretKeyValue[secret.Key] = TemplateSecret{ + secretKeyValue[secret.Key] = model.SecretTemplateOptions{ Value: secret.Value, SecretPath: secret.SecretPath, } } - for tmplKey, userTmpl := range managedTemplateData.Data { - tmpl, err := template.New("secret-templates").Parse(userTmpl) + for templateKey, userTemplate := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTemplate) if err != nil { - return fmt.Errorf("Unable to compile template: %s", tmplKey, err) + return fmt.Errorf("Unable to compile template: %s", templateKey, err) } buf := bytes.NewBuffer(nil) err = tmpl.Execute(buf, secretKeyValue) if err != nil { - return fmt.Errorf("Unable to execute template: %s", tmplKey, err) + return fmt.Errorf("Unable to execute template: %s", templateKey, err) } - plainProcessedSecrets[tmplKey] = buf.Bytes() + plainProcessedSecrets[templateKey] = buf.Bytes() } } @@ -330,26 +325,26 @@ func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context } if managedTemplateData != nil { - secretKeyValue := make(map[string]TemplateSecret) + secretKeyValue := make(map[string]model.SecretTemplateOptions) for _, secret := range secretsFromAPI { - secretKeyValue[secret.Key] = TemplateSecret{ + secretKeyValue[secret.Key] = model.SecretTemplateOptions{ Value: secret.Value, SecretPath: secret.SecretPath, } } - for tmplKey, userTmpl := range managedTemplateData.Data { - tmpl, err := template.New("secret-templates").Parse(userTmpl) + for templateKey, userTemplate := range managedTemplateData.Data { + tmpl, err := template.New("secret-templates").Parse(userTemplate) if err != nil { - return fmt.Errorf("Unable to compile template: %s", tmplKey, err) + return fmt.Errorf("Unable to compile template: %s", templateKey, err) } buf := bytes.NewBuffer(nil) err = tmpl.Execute(buf, secretKeyValue) if err != nil { - return fmt.Errorf("Unable to execute template: %s", tmplKey, err) + return fmt.Errorf("Unable to execute template: %s", templateKey, err) } - plainProcessedSecrets[tmplKey] = buf.Bytes() + plainProcessedSecrets[templateKey] = buf.Bytes() } } diff --git a/k8-operator/main.go b/k8-operator/main.go index d400545ff..50c0cda00 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -36,7 +36,7 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8082", "The address the metric endpoint binds to.") + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ diff --git a/k8-operator/packages/model/model.go b/k8-operator/packages/model/model.go index aa68597f5..e3328061c 100644 --- a/k8-operator/packages/model/model.go +++ b/k8-operator/packages/model/model.go @@ -21,5 +21,10 @@ type SingleEnvironmentVariable struct { Value string `json:"value"` SecretPath string `json:"secretPath"` Type string `json:"type"` - ID string `json:"_id"` + ID string `json:"id"` +} + +type SecretTemplateOptions struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` } From fbfe694fc0003ef4f9d62086929cfff66f4899f0 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 5 Dec 2024 09:13:39 -0800 Subject: [PATCH 58/70] improvement: add overflow handling to integration filter dropdown --- .../IntegrationsSection/components/IntegrationsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx index 05c4a2d32..ea2ecd888 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx @@ -246,7 +246,7 @@ export const IntegrationsTable = ({ - + Status {Object.values(IntegrationStatus).map((status) => ( Date: Fri, 6 Dec 2024 01:16:28 +0530 Subject: [PATCH 59/70] feat: changed both IN operator contains name to In itself --- .../components/IdentityManagementPermissionConditions.tsx | 2 +- .../components/PermissionConditionHelpers.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx index 9409ce46d..8a62c6ad9 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/IdentityManagementPermissionConditions.tsx @@ -95,7 +95,7 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable > Equal Not Equal - Contains + In )} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx index 212f38e92..9120f0364 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/PermissionConditionHelpers.tsx @@ -27,7 +27,7 @@ export const renderOperatorSelectItems = (type: string) => { Equal Not Equal Glob Match - Contains + In ); }; From 623a99be0e2a5a4a9e49ad51b231b537aa5bc79b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 29 Nov 2024 23:18:15 +0400 Subject: [PATCH 60/70] fix: consolidate reqId and requestId fields --- .../src/@types/fastify-request-context.d.ts | 2 +- backend/src/lib/logger/logger.ts | 12 ++++----- backend/src/server/app.ts | 6 ++--- backend/src/server/plugins/error-handler.ts | 26 +++++++++---------- backend/src/server/routes/sanitizedSchemas.ts | 12 ++++----- frontend/src/hooks/api/types.ts | 8 +++--- frontend/src/reactQuery.tsx | 12 ++++----- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/backend/src/@types/fastify-request-context.d.ts b/backend/src/@types/fastify-request-context.d.ts index caef4d5b2..fc8d94e07 100644 --- a/backend/src/@types/fastify-request-context.d.ts +++ b/backend/src/@types/fastify-request-context.d.ts @@ -2,6 +2,6 @@ import "@fastify/request-context"; declare module "@fastify/request-context" { interface RequestContextData { - requestId: string; + reqId: string; } } diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index 5563499e5..9676496f7 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -89,9 +89,9 @@ const redactedKeys = [ const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID"; -const extractRequestId = () => { +const extractReqId = () => { try { - return requestContext.get("requestId") || UNKNOWN_REQUEST_ID; + return requestContext.get("reqId") || UNKNOWN_REQUEST_ID; } catch (err) { console.log("failed to get request context", err); return UNKNOWN_REQUEST_ID; @@ -133,22 +133,22 @@ export const initLogger = async () => { const wrapLogger = (originalLogger: Logger): CustomLogger => { // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).info(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).error(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).warn(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ requestId: extractRequestId() }).debug(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args); }; return originalLogger; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 0b01dcc93..52fc989cf 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -112,9 +112,9 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(maintenanceMode); await server.register(fastifyRequestContext, { - defaultStoreValues: (request) => ({ - requestId: request.id, - log: request.log.child({ requestId: request.id }) + defaultStoreValues: (req) => ({ + reqId: req.id, + log: req.log.child({ reqId: req.id }) }) }); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 920f17efb..ac4803c98 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -40,42 +40,42 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider if (error instanceof BadRequestError) { void res .status(HttpStatusCodes.BadRequest) - .send({ requestId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); + .send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); } else if (error instanceof NotFoundError) { void res .status(HttpStatusCodes.NotFound) - .send({ requestId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); + .send({ reqId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name }); } else if (error instanceof UnauthorizedError) { void res.status(HttpStatusCodes.Unauthorized).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Unauthorized, message: error.message, error: error.name }); } else if (error instanceof DatabaseError || error instanceof InternalServerError) { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); } else if (error instanceof GatewayTimeoutError) { void res.status(HttpStatusCodes.GatewayTimeout).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.GatewayTimeout, message: error.message, error: error.name }); } else if (error instanceof ZodError) { void res.status(HttpStatusCodes.UnprocessableContent).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.UnprocessableContent, error: "ValidationFailure", message: error.issues }); } else if (error instanceof ForbiddenError) { void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}`, @@ -88,28 +88,28 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider }); } else if (error instanceof ForbiddenRequestError) { void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, message: error.message, error: error.name }); } else if (error instanceof RateLimitError) { void res.status(HttpStatusCodes.TooManyRequests).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.TooManyRequests, message: error.message, error: error.name }); } else if (error instanceof ScimRequestError) { void res.status(error.status).send({ - requestId: req.id, + reqId: req.id, schemas: error.schemas, status: error.status, detail: error.detail }); } else if (error instanceof OidcAuthError) { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: error.message, error: error.name @@ -128,14 +128,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider } void res.status(HttpStatusCodes.Forbidden).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.Forbidden, error: "TokenError", message: errorMessage }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ - requestId: req.id, + reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, error: "InternalServerError", message: "Something went wrong" diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index e24c5db6b..69a648d9e 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -30,25 +30,25 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({ export const DefaultResponseErrorsSchema = { 400: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(400), message: z.string(), error: z.string() }), 404: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(404), message: z.string(), error: z.string() }), 401: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(401), message: z.string(), error: z.string() }), 403: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(403), message: z.string(), details: z.any().optional(), @@ -56,13 +56,13 @@ export const DefaultResponseErrorsSchema = { }), // Zod errors return a message of varying shapes and sizes, so z.any() is used here 422: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(422), message: z.any(), error: z.string() }), 500: z.object({ - requestId: z.string(), + reqId: z.string(), statusCode: z.literal(500), message: z.string(), error: z.string() diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index b387d33d1..c03358b42 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -51,26 +51,26 @@ export enum ApiErrorTypes { export type TApiErrors = | { - requestId: string; + reqId: string; error: ApiErrorTypes.ValidationError; message: ZodIssue[]; statusCode: 422; } | { - requestId: string; + reqId: string; error: ApiErrorTypes.UnauthorizedError; message: string; statusCode: 401; } | { - requestId: string; + reqId: string; error: ApiErrorTypes.ForbiddenError; message: string; details: PureAbility["rules"]; statusCode: 403; } | { - requestId: string; + reqId: string; statusCode: 400; message: string; error: ApiErrorTypes.BadRequestError; diff --git a/frontend/src/reactQuery.tsx b/frontend/src/reactQuery.tsx index abe1b0a11..0cfe0180f 100644 --- a/frontend/src/reactQuery.tsx +++ b/frontend/src/reactQuery.tsx @@ -64,9 +64,9 @@ export const queryClient = new QueryClient({ ), copyActions: [ { - value: serverResponse.requestId, + value: serverResponse.reqId, name: "Request ID", - label: `Request ID: ${serverResponse.requestId}` + label: `Request ID: ${serverResponse.reqId}` } ] }, @@ -169,9 +169,9 @@ export const queryClient = new QueryClient({ ) : undefined, copyActions: [ { - value: serverResponse.requestId, + value: serverResponse.reqId, name: "Request ID", - label: `Request ID: ${serverResponse.requestId}` + label: `Request ID: ${serverResponse.reqId}` } ] }, @@ -185,9 +185,9 @@ export const queryClient = new QueryClient({ text: `${serverResponse.message}.`, copyActions: [ { - value: serverResponse.requestId, + value: serverResponse.reqId, name: "Request ID", - label: `Request ID: ${serverResponse.requestId}` + label: `Request ID: ${serverResponse.reqId}` } ] }); From a91f64f742c95f97fec93bfa9f03c4a39b4619d9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sat, 7 Dec 2024 01:20:13 +0400 Subject: [PATCH 61/70] fix(k8-operator): missing generation, helm, and error formatting --- helm-charts/secrets-operator/Chart.yaml | 4 +-- .../templates/infisicalsecret-crd.yaml | 14 +++++++++ helm-charts/secrets-operator/values.yaml | 2 +- .../api/v1alpha1/zz_generated.deepcopy.go | 31 +++++++++++++++++-- .../controllers/infisicalsecret_helper.go | 8 ++--- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 8ff17cdaa..f212ce4eb 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.7.4 +version: v0.7.5 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.7.4" +appVersion: "v0.7.5" diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 3e0d6ab72..9d300eaf4 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -282,6 +282,20 @@ spec: description: 'The Kubernetes Secret type (experimental feature). More info: https://kubernetes.io/docs/concepts/configuration/secret/#secret-types' type: string + template: + description: The template to transform the secret data + properties: + data: + additionalProperties: + type: string + description: The template key values + type: object + includeAllSecrets: + description: This injects all retrieved secrets into the top + level of your template. Secrets defined in the template will + take precedence over the injected ones. + type: boolean + type: object required: - secretName - secretNamespace diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index c2ad28f2b..dc342c5ac 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.7.4 + tag: v0.7.5 resources: limits: cpu: 500m diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index dd242910c..41e4d3f20 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -133,7 +133,7 @@ func (in *InfisicalSecret) DeepCopyInto(out *InfisicalSecret) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -192,7 +192,7 @@ func (in *InfisicalSecretSpec) DeepCopyInto(out *InfisicalSecretSpec) { *out = *in out.TokenSecretReference = in.TokenSecretReference out.Authentication = in.Authentication - out.ManagedSecretReference = in.ManagedSecretReference + in.ManagedSecretReference.DeepCopyInto(&out.ManagedSecretReference) out.TLS = in.TLS } @@ -228,6 +228,28 @@ func (in *InfisicalSecretStatus) DeepCopy() *InfisicalSecretStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InfisicalSecretTemplate) DeepCopyInto(out *InfisicalSecretTemplate) { + *out = *in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalSecretTemplate. +func (in *InfisicalSecretTemplate) DeepCopy() *InfisicalSecretTemplate { + if in == nil { + return nil + } + out := new(InfisicalSecretTemplate) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeSecretReference) DeepCopyInto(out *KubeSecretReference) { *out = *in @@ -293,6 +315,11 @@ func (in *MachineIdentityScopeInWorkspace) DeepCopy() *MachineIdentityScopeInWor // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MangedKubeSecretConfig) DeepCopyInto(out *MangedKubeSecretConfig) { *out = *in + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(InfisicalSecretTemplate) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MangedKubeSecretConfig. diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index cc9b5c68e..cdf2a4a26 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -250,13 +250,13 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context for templateKey, userTemplate := range managedTemplateData.Data { tmpl, err := template.New("secret-templates").Parse(userTemplate) if err != nil { - return fmt.Errorf("Unable to compile template: %s", templateKey, err) + return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) } buf := bytes.NewBuffer(nil) err = tmpl.Execute(buf, secretKeyValue) if err != nil { - return fmt.Errorf("Unable to execute template: %s", templateKey, err) + return fmt.Errorf("unable to execute template: %s [err=%v]", templateKey, err) } plainProcessedSecrets[templateKey] = buf.Bytes() } @@ -336,13 +336,13 @@ func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context for templateKey, userTemplate := range managedTemplateData.Data { tmpl, err := template.New("secret-templates").Parse(userTemplate) if err != nil { - return fmt.Errorf("Unable to compile template: %s", templateKey, err) + return fmt.Errorf("unable to compile template: %s [err=%v]", templateKey, err) } buf := bytes.NewBuffer(nil) err = tmpl.Execute(buf, secretKeyValue) if err != nil { - return fmt.Errorf("Unable to execute template: %s", templateKey, err) + return fmt.Errorf("unable to execute template: %s [err=%v]", templateKey, err) } plainProcessedSecrets[templateKey] = buf.Bytes() } From 3c588beebee352d849885327a5e2f5ca32d8b3d8 Mon Sep 17 00:00:00 2001 From: McPizza Date: Sun, 8 Dec 2024 14:02:33 +0100 Subject: [PATCH 62/70] improvement: Slug Validation Errors (#2788) * improvement: Slug Validation Errors --- .../src/ee/routes/v1/dynamic-secret-router.ts | 12 +----- backend/src/ee/routes/v1/group-router.ts | 21 ++-------- ...ity-project-additional-privilege-router.ts | 40 +++---------------- backend/src/ee/routes/v1/org-role-router.ts | 29 +++++--------- .../src/ee/routes/v1/project-role-router.ts | 27 +++---------- .../ee/routes/v1/project-template-router.ts | 35 +++++++--------- .../v1/user-additional-privilege-router.ts | 23 ++--------- ...ity-project-additional-privilege-router.ts | 24 ++--------- .../src/ee/routes/v2/project-role-router.ts | 27 +++---------- backend/src/server/lib/schemas.ts | 23 +++++++++++ backend/src/server/routes/v1/cmek-router.ts | 12 +----- .../external-group-org-role-mapping-router.ts | 10 +---- .../server/routes/v1/organization-router.ts | 18 ++------- .../server/routes/v1/project-env-router.ts | 19 ++------- .../src/server/routes/v1/secret-tag-router.ts | 20 ++-------- backend/src/server/routes/v1/slack-router.ts | 17 ++------ .../src/server/routes/v2/project-router.ts | 36 ++++------------- .../tags/CreateTagModal/CreateTagModal.tsx | 10 +---- frontend/src/hooks/api/kms/types.ts | 11 ++--- frontend/src/lib/schemas/slugSchema.ts | 29 +++++++++----- .../Org/RolePage/components/RoleModal.tsx | 3 +- .../Project/KmsPage/components/CmekModal.tsx | 11 +---- .../Project/RolePage/components/RoleModal.tsx | 3 +- .../SlackIntegrationForm.tsx | 10 +---- .../ProjectTemplateEditRoleForm.tsx | 2 +- .../ProjectTemplateEnvironmentsForm.tsx | 2 +- .../ProjectTemplateDetailsModal.tsx | 12 +----- .../AddEnvironmentModal.tsx | 30 +++++++------- .../UpdateEnvironmentModal.tsx | 23 ++++------- .../SecretTagsSection/AddSecretTagModal.tsx | 6 +-- 30 files changed, 160 insertions(+), 385 deletions(-) create mode 100644 backend/src/server/lib/schemas.ts diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 4b1566c55..1d24c0578 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -8,6 +7,7 @@ import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,15 +48,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), - name: z - .string() - .describe(DYNAMIC_SECRETS.CREATE.name) - .min(1) - .toLowerCase() - .max(64) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) + name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 780e5ec00..b2f1762d1 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; import { GROUPS } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -14,15 +14,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(GROUPS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(GROUPS.CREATE.slug), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) }), response: { @@ -100,14 +92,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { body: z .object({ name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(GROUPS.UPDATE.slug), + slug: slugSchema({ min: 5, max: 36 }).describe(GROUPS.UPDATE.slug), role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) }) .partial(), diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index d342f95ce..1eadb4051 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -8,6 +8,7 @@ import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ProjectPermissionSchema, @@ -33,17 +34,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -77,7 +68,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: false, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -103,17 +94,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -159,7 +140,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: true, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -189,16 +170,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), privilegeDetails: z .object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 232f4b0b5..30f31c545 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,17 +18,10 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z - .string() - .min(1) - .trim() - .refine( - (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, { - message: "Slug must be a valid" - }), + slug: slugSchema({ min: 1, max: 64 }).refine( + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -94,17 +87,13 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z - .string() - .trim() - .optional() + // TODO: Switch to slugSchema after verifying correct methods with Akhil - Omar 11/24 + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.keys(OrgMembershipRole).includes(val), "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" - }), + .optional(), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array().optional() diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index ba2c0aa9f..0fa35ab1d 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,5 +1,4 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; @@ -9,6 +8,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -32,18 +32,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ max: 64 }) .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), @@ -94,21 +87,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !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" - }), + .describe(PROJECT_ROLE.UPDATE.slug) + .optional(), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index 5b115ab4e..60f93d65d 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; @@ -8,22 +7,13 @@ import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-tem import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { ProjectTemplates } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { AuthMode } from "@app/services/auth/auth-type"; const MAX_JSON_SIZE_LIMIT_IN_BYTES = 32_768; -const SlugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Must be valid slug format" - }); - const isReservedRoleSlug = (slug: string) => Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); @@ -34,14 +24,14 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ roles: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: UnpackedPermissionSchema.array() }) .array(), environments: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -50,7 +40,7 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ const ProjectTemplateRolesSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: ProjectPermissionV2Schema.array() }) .array() @@ -78,7 +68,7 @@ const ProjectTemplateRolesSchema = z const ProjectTemplateEnvironmentsSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -188,9 +178,11 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) schema: { description: "Create a project template.", body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }).describe(ProjectTemplates.CREATE.name), + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) + .describe(ProjectTemplates.CREATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( @@ -230,9 +222,10 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) description: "Update a project template.", params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.UPDATE.templateId) }), body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }) + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) .optional() .describe(ProjectTemplates.UPDATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description), diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index e58a6335b..bb3e179dd 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/pr import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedUserProjectAdditionalPrivilegeSchema } from "@app/server/routes/santizedSchemas/user-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,17 +22,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr schema: { body: z.object({ projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -87,15 +78,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), body: z .object({ - slug: z - .string() - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 5df03f68d..7934c3f90 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-p import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedIdentityPrivilegeSchema } from "@app/server/routes/santizedSchemas/identitiy-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -28,17 +29,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId), projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -100,16 +91,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F id: z.string().trim().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.id) }), body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission), diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 70511ce87..0152104c6 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -1,11 +1,11 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -29,18 +29,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ min: 1, max: 64 }) .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), @@ -90,21 +83,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !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" - }), + .optional() + .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts new file mode 100644 index 000000000..ed97cb7d0 --- /dev/null +++ b/backend/src/server/lib/schemas.ts @@ -0,0 +1,23 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 18d13e67f..e3982f3d6 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; @@ -8,19 +7,12 @@ import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CmekOrderBy } from "@app/services/cmek/cmek-types"; -const keyNameSchema = z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be slug friendly" - }); +const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); const base64Schema = z.string().superRefine((val, ctx) => { diff --git a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts index 032deda7d..67db5de6f 100644 --- a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts +++ b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ExternalGroupOrgRoleMappingsSchema } from "@app/db/schemas/external-group-org-role-mappings"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,13 +48,7 @@ export const registerExternalGroupOrgRoleMappingRouter = async (server: FastifyZ mappings: z .object({ groupName: z.string().trim().min(1), - roleSlug: z - .string() - .min(1) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Role must be a valid slug" - }) + roleSlug: slugSchema({ max: 64 }) }) .array() }), diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 07f795779..1327faeb1 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -14,6 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; @@ -243,22 +243,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { params: z.object({ organizationId: z.string().trim() }), body: z.object({ name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), - slug: z - .string() - .trim() - .max(64, { message: "Slug must be 64 or fewer characters" }) - .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") - .optional(), + slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), - defaultMembershipRoleSlug: z - .string() - .min(1) - .trim() - .refine((v) => slugify(v) === v, { - message: "Membership role must be a valid slug" - }) - .optional(), + defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index c5ded83e4..705016696 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ENVIRONMENTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -124,13 +124,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), position: z.number().min(1).optional().describe(ENVIRONMENTS.CREATE.position), - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.CREATE.slug) + slug: slugSchema({ max: 64 }).describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -188,14 +182,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z - .string() - .trim() - .optional() - .refine((v) => !v || slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.UPDATE.slug), + slug: slugSchema({ max: 64 }).optional().describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 7d696999e..ed9837084 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; import { SECRET_TAGS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -111,14 +111,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.CREATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.CREATE.slug), color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { @@ -153,14 +146,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.UPDATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.UPDATE.slug), color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) }), response: { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 0601e2d1f..f05aa18f0 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SlackIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -35,12 +35,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), + slug: slugSchema({ max: 64 }), description: z.string().optional() }), response: { @@ -288,13 +283,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional(), + slug: slugSchema({ max: 64 }).optional(), description: z.string().optional() }), response: { diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e271eb0e..0df88e38c 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -12,6 +11,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -27,14 +27,6 @@ const projectWithEnv = SanitizedProjectSchema.extend({ environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() }); -const slugSchema = z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be at least 5 character but no more than 36" - }); - export const registerProjectRouter = async (server: FastifyZodProvider) => { /* Get project key */ server.route({ @@ -162,21 +154,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECTS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(PROJECTS.CREATE.slug), kmsKeyId: z.string().optional(), - template: z - .string() - .refine((v) => slugify(v) === v, { - message: "Template name must be in slug format" - }) + template: slugSchema({ field: "Template Name", max: 64 }) .optional() .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template) @@ -244,7 +224,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema.describe("The slug of the project to delete.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.") }), response: { 200: SanitizedProjectSchema @@ -278,7 +258,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to get.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -311,7 +291,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to update.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.") }), body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), @@ -354,7 +334,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CAS.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug) }), querystring: z.object({ status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), @@ -395,7 +375,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug) }), querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index a2fde465d..23389cbca 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,6 +17,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; export const secretTagsColors = [ { @@ -88,13 +88,7 @@ type Props = { }; const createTagSchema = z.object({ - slug: z - .string() - .trim() - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ min: 1, field: "Tag Slug" }), color: z.string().trim() }); diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 3e4b69880..51a972fc0 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,6 +1,7 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { slugSchema } from "@app/lib/schemas"; + export type Kms = { id: string; description: string; @@ -119,13 +120,7 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ ]); export const AddExternalKmsSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + name: slugSchema({ min: 1, field: "Alias" }), description: z.string().trim().optional(), provider: ExternalKmsInputSchema }); diff --git a/frontend/src/lib/schemas/slugSchema.ts b/frontend/src/lib/schemas/slugSchema.ts index df74b99e3..ed97cb7d0 100644 --- a/frontend/src/lib/schemas/slugSchema.ts +++ b/frontend/src/lib/schemas/slugSchema.ts @@ -1,12 +1,23 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -export const slugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Invalid slug format" - }); +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index ab909d931..da2ad89c5 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -9,12 +9,13 @@ import { Button, FormControl, Input, Modal, ModalContent } from "@app/components import { useOrganization } from "@app/context"; import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z .object({ name: z.string(), description: z.string(), - slug: z.string() + slug: slugSchema({ min: 1 }) }) .required(); diff --git a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx index 4b6bd9f39..8735b047d 100644 --- a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx +++ b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -17,16 +16,10 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .min(1) - .toLowerCase() - .max(32) - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) }); diff --git a/frontend/src/views/Project/RolePage/components/RoleModal.tsx b/frontend/src/views/Project/RolePage/components/RoleModal.tsx index 5a87b4a61..cf8cab03b 100644 --- a/frontend/src/views/Project/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Project/RolePage/components/RoleModal.tsx @@ -13,12 +13,13 @@ import { useUpdateProjectRole } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z .object({ name: z.string(), description: z.string(), - slug: z.string() + slug: slugSchema({ min: 1 }) }) .required(); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx index 281061db4..93c24586e 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import axios from "axios"; import { z } from "zod"; @@ -15,6 +14,7 @@ import { useGetSlackIntegrationById, useUpdateSlackIntegration } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; type Props = { id?: string; @@ -22,13 +22,7 @@ type Props = { }; const slackFormSchema = z.object({ - slug: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + slug: slugSchema({ min: 1, field: "Alias" }), description: z.string().optional() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx index 0e703798e..7cb6d5d09 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx @@ -31,7 +31,7 @@ type Props = { }; const formSchema = z.object({ - slug: slugSchema, + slug: slugSchema(), name: z.string().trim().min(1), permissions: projectRoleFormSchema.shape.permissions }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index 677a7c773..b72d12c73 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -32,7 +32,7 @@ const formSchema = z.object({ environments: z .object({ name: z.string().trim().min(1), - slug: slugSchema + slug: slugSchema({ min: 1, max: 32 }) }) .array() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 1f65df795..e601e0319 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -1,6 +1,5 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,17 +17,10 @@ import { useCreateProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional() }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 68bbeb840..00f160e75 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["createEnv"]>; @@ -15,26 +15,20 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup +const schema = z.object({ + environmentName: z .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() + .min(1, { message: "Environment Name field must be at least 1 character" }), + environmentSlug: slugSchema() }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useCreateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema) + resolver: zodResolver(schema) }); const onFormSubmit = async ({ environmentName, environmentSlug }: FormData) => { @@ -112,7 +106,11 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle Create -
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx index c6b2152cc..ad11c2381 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useUpdateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["updateEnv"]>; @@ -15,25 +15,18 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - name: yup.string().label("Environment Name").required(), - slug: yup - .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() +const schema = z.object({ + name: z.string(), + slug: slugSchema({ min: 1 }) }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useUpdateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema), + resolver: zodResolver(schema), values: popUp.updateEnv.data as FormData }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx index 75f6b69bf..96c667050 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -8,11 +7,10 @@ import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@ap import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z.object({ - slug: z.string().refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }) + slug: slugSchema({ min: 1, field: "Tag Slug" }) }); export type FormData = z.infer; From c6482353909842da5049ac40f3623d906c7ca404 Mon Sep 17 00:00:00 2001 From: McPizza Date: Sun, 8 Dec 2024 19:13:54 +0100 Subject: [PATCH 63/70] hotfix: add missing package import (#2850) --- frontend/src/hooks/api/kms/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 51a972fc0..73b821b1a 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { slugSchema } from "@app/lib/schemas"; From 3f6b1fe3bdc4f845e73ecea2531b8a5c08f53dca Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 9 Dec 2024 13:17:04 +0800 Subject: [PATCH 64/70] misc: add ssl setting for pg boss --- backend/e2e-test/vitest-environment-knex.ts | 2 +- backend/src/main.ts | 6 +++++- backend/src/queue/queue-service.ts | 13 +++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 66fd4dc75..58f2bffeb 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -53,7 +53,7 @@ export default { extension: "ts" }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(cfg.REDIS_URL, { dbConnectionUrl: cfg.DB_CONNECTION_URI }); const keyStore = keyStoreFactory(cfg.REDIS_URL); const hsmModule = initializeHsmModule(); diff --git a/backend/src/main.ts b/backend/src/main.ts index ca85625c2..850298f89 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -57,7 +57,11 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(appCfg.REDIS_URL, { + dbConnectionUrl: appCfg.DB_CONNECTION_URI, + dbRootCert: appCfg.DB_ROOT_CERT + }); + await queue.initialize(); const keyStore = keyStoreFactory(appCfg.REDIS_URL); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8479a249c..051fe9cbd 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -187,7 +187,10 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => { +export const queueServiceFactory = ( + redisUrl: string, + { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } +) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, @@ -198,7 +201,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried - deleteAfterSeconds: 30 + deleteAfterSeconds: 30, + ssl: dbRootCert + ? { + rejectUnauthorized: true, + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + } + : false }); const queueContainerPg = {} as Record; From 40d69d46203f8758f8bc857ac3b3f78f297328fb Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 19:15:17 +0530 Subject: [PATCH 65/70] feat: added endpoint to update integration auth --- backend/src/lib/api-docs/constants.ts | 5 + .../routes/v1/integration-auth-router.ts | 61 ++++++++ .../server/routes/v1/integration-router.ts | 4 +- .../integration-auth-service.ts | 145 ++++++++++++++++++ .../integration-auth-types.ts | 5 + .../integration/integration-service.ts | 6 +- .../services/integration/integration-types.ts | 2 + 7 files changed, 226 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da29..518654da1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1032,6 +1032,9 @@ export const INTEGRATION_AUTH = { DELETE_BY_ID: { integrationAuthId: "The ID of integration authentication object to delete." }, + UPDATE_BY_ID: { + integrationAuthId: "The ID of integration authentication object to update." + }, CREATE_ACCESS_TOKEN: { workspaceId: "The ID of the project to create the integration auth for.", integration: "The slug of integration for the auth object.", @@ -1088,11 +1091,13 @@ export const INTEGRATION = { }, UPDATE: { integrationId: "The ID of the integration object.", + region: "AWS region to sync secrets to.", app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", appId: "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", isActive: "Whether the integration should be active or disabled.", secretPath: "The path of the secrets to sync secrets from.", + path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault.", owner: "External integration providers service entity owner. Used in Github.", targetEnvironment: "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 575544cc7..ea7b6f917 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -82,6 +82,67 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/:integrationAuthId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update the integration authentication object required for syncing secrets.", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) + }), + body: z.object({ + integration: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), + accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), + awsAssumeIamRoleArn: z + .string() + .url() + .trim() + .optional() + .describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn), + url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url), + namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace), + refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken) + }), + response: { + 200: z.object({ + integrationAuth: integrationAuthPubSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.updateIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + integrationAuthId: req.query.integrationAuthId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integrationAuth.projectId, + event: { + type: EventType.AUTHORIZE_INTEGRATION, + metadata: { + integration: integrationAuth.integration + } + } + }); + return { integrationAuth }; + } + }); + server.route({ method: "DELETE", url: "/", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 40141e2c0..059d24463 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -141,7 +141,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment), - metadata: IntegrationMetadataSchema.optional() + path: z.string().trim().optional().describe(INTEGRATION.UPDATE.path), + metadata: IntegrationMetadataSchema.optional(), + region: z.string().trim().optional().describe(INTEGRATION.UPDATE.region) }), response: { 200: z.object({ diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index be1a8d53c..6768e12bf 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -55,6 +55,7 @@ import { TOctopusDeployVariableSet, TSaveIntegrationAccessTokenDTO, TTeamCityBuildConfig, + TUpdateIntegrationAuthDTO, TVercelBranches } from "./integration-auth-types"; import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list"; @@ -368,6 +369,149 @@ export const integrationAuthServiceFactory = ({ return integrationAuthDAL.create(updateDoc); }; + const updateIntegrationAuth = async ({ + integrationAuthId, + refreshToken, + actorId, + integration: newIntegration, + url, + actor, + actorOrgId, + actorAuthMethod, + accessId, + namespace, + accessToken, + awsAssumeIamRoleArn + }: TUpdateIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDAL.findById(integrationAuthId); + if (!integrationAuth) { + throw new NotFoundError({ message: `Integration auth with id ${integrationAuthId} not found.` }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + + const { projectId } = integrationAuth; + const integration = newIntegration || integrationAuth.integration; + if (!Object.values(Integrations).includes(integration as Integrations)) + throw new BadRequestError({ message: "Invalid integration" }); + const updateDoc: TIntegrationAuthsInsert = { + projectId, + integration, + namespace, + url, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + ...(integration === Integrations.GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) + }; + + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.refreshToken) + }).cipherTextBlob; + updateDoc.encryptedRefresh = refreshEncToken; + + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (accessId) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessId) + }).cipherTextBlob; + updateDoc.encryptedAccessId = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({ + plainText: Buffer.from(awsAssumeIamRoleArn) + }).cipherTextBlob; + updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted; + updateDoc.encryptedAccess = null; + updateDoc.encryptedAccessId = null; + } + } + } else { + if (!botKey) throw new NotFoundError({ message: `Project bot key for project with ID '${projectId}' not found` }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + if (accessId) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, botKey); + updateDoc.accessIdIV = accessEncToken.iv; + updateDoc.accessIdTag = accessEncToken.tag; + updateDoc.accessIdCiphertext = accessEncToken.ciphertext; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, botKey); + updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext; + updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv; + updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag; + } + } + } + + return integrationAuthDAL.updateById(integrationAuthId, updateDoc); + }; + // helper function const getIntegrationAccessToken = async ( integrationAuth: TIntegrationAuths, @@ -1615,6 +1759,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationAuth, oauthExchange, saveIntegrationToken, + updateIntegrationAuth, deleteIntegrationAuthById, deleteIntegrationAuths, getIntegrationAuthTeams, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 80e8d6c36..3ffa6959a 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -22,6 +22,11 @@ export type TSaveIntegrationAccessTokenDTO = { awsAssumeIamRoleArn?: string; } & TProjectPermission; +export type TUpdateIntegrationAuthDTO = Omit & { + integrationAuthId: string; + integration?: string; +}; + export type TDeleteIntegrationAuthsDTO = TProjectPermission & { integration: string; projectId: string; diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 1db10405d..a990b1ca6 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -151,7 +151,9 @@ export const integrationServiceFactory = ({ isActive, environment, secretPath, - metadata + region, + metadata, + path }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` }); @@ -192,7 +194,9 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, + region, secretPath, + path, metadata: { ...(integration.metadata as object), ...metadata diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index a27c4f6ac..f662affd8 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -49,6 +49,8 @@ export type TUpdateIntegrationDTO = { appId?: string; isActive?: boolean; secretPath?: string; + region?: string; + path?: string; targetEnvironment?: string; owner?: string; environment?: string; From 826916399bb3a95d969327dd0c83f91313e3789c Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 20:16:34 +0530 Subject: [PATCH 66/70] feat: changed integration option to nativeEnum in zod and added audit log event --- backend/src/ee/services/audit-log/audit-log-types.ts | 9 +++++++++ backend/src/server/routes/v1/integration-auth-router.ts | 5 +++-- .../integration-auth/integration-auth-service.ts | 3 +-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 51090e594..601436d1b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -60,6 +60,7 @@ export enum EventType { DELETE_SECRETS = "delete-secrets", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", @@ -357,6 +358,13 @@ interface AuthorizeIntegrationEvent { }; } +interface UpdateIntegrationAuthEvent { + type: EventType.UPDATE_INTEGRATION_AUTH; + metadata: { + integration: string; + }; +} + interface UnauthorizeIntegrationEvent { type: EventType.UNAUTHORIZE_INTEGRATION; metadata: { @@ -1680,6 +1688,7 @@ export type Event = | DeleteSecretBatchEvent | GetWorkspaceKeyEvent | AuthorizeIntegrationEvent + | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index ea7b6f917..5e652283c 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -6,6 +6,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { OctopusDeployScope } from "@app/services/integration-auth/integration-auth-types"; +import { Integrations } from "@app/services/integration-auth/integration-list"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -100,7 +101,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) }), body: z.object({ - integration: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + integration: z.nativeEnum(Integrations).optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), awsAssumeIamRoleArn: z @@ -133,7 +134,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) ...req.auditLogInfo, projectId: integrationAuth.projectId, event: { - type: EventType.AUTHORIZE_INTEGRATION, + type: EventType.UPDATE_INTEGRATION_AUTH, metadata: { integration: integrationAuth.integration } diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 6768e12bf..42a3f038b 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -399,8 +399,7 @@ export const integrationAuthServiceFactory = ({ const { projectId } = integrationAuth; const integration = newIntegration || integrationAuth.integration; - if (!Object.values(Integrations).includes(integration as Integrations)) - throw new BadRequestError({ message: "Invalid integration" }); + const updateDoc: TIntegrationAuthsInsert = { projectId, integration, From a808b6d4a0145cb0667b30a570793e129541ed92 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 20:24:30 +0530 Subject: [PATCH 67/70] feat: added new audit log event in ui --- frontend/src/hooks/api/auditLogs/constants.tsx | 1 + frontend/src/hooks/api/auditLogs/enums.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 404592908..a75767108 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_SECRET]: "Delete secret", [EventType.GET_WORKSPACE_KEY]: "Read project key", [EventType.AUTHORIZE_INTEGRATION]: "Authorize integration", + [EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth", [EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration", [EventType.CREATE_INTEGRATION]: "Create integration", [EventType.DELETE_INTEGRATION]: "Delete integration", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 1db55d739..0b0c44d7b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -23,6 +23,7 @@ export enum EventType { DELETE_SECRET = "delete-secret", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", From 97f85fa8d94a72f8cf3dcd16f8104272be1c4a06 Mon Sep 17 00:00:00 2001 From: McPizza Date: Mon, 9 Dec 2024 20:03:45 +0100 Subject: [PATCH 68/70] fix(Approval Workflows): Workflows keep approval history after deletion (#2834) * improvement: Approval Workflows can be deleted while maintaining history Co-authored-by: Daniel Hougaard --- ...5840_allow-disabling-approval-workflows.ts | 59 +++++++++++++++++++ .../db/schemas/access-approval-policies.ts | 3 +- .../db/schemas/secret-approval-policies.ts | 3 +- .../v1/access-approval-request-router.ts | 3 +- .../v1/secret-approval-request-router.ts | 6 +- .../access-approval-policy-dal.ts | 7 ++- .../access-approval-policy-service.ts | 48 +++++++++++++-- .../access-approval-request-dal.ts | 15 +++-- .../access-approval-request-service.ts | 9 +++ .../secret-approval-policy-dal.ts | 7 ++- .../secret-approval-policy-service.ts | 22 +++++-- .../secret-approval-request-dal.ts | 12 +++- .../secret-approval-request-service.ts | 26 ++++++-- backend/src/server/routes/index.ts | 8 ++- .../src/hooks/api/accessApproval/types.ts | 7 ++- .../AccessApprovalRequest.tsx | 4 +- 16 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts diff --git a/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts new file mode 100644 index 000000000..c7fb6fe39 --- /dev/null +++ b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts @@ -0,0 +1,59 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (!hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + if (!hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("SET NULL"); + }); +} + +export async function down(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + if (hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index f4c525a4f..3650face9 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 94aeba050..06ae3e5c4 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7dbb62fc2..4aa26eb36 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -109,7 +109,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), reviewers: z .object({ diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 5fbf784f6..e1c56583c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -52,7 +52,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }) .array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -260,7 +261,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 220701410..e14451498 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -139,5 +139,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...accessApprovalPolicyOrm, find, findById }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await accessApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index ee7cf2572..24436e695 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -8,7 +8,11 @@ import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; +import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal"; +import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { @@ -21,7 +25,7 @@ import { TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; -type TSecretApprovalPolicyServiceFactoryDep = { +type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; permissionService: Pick; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; @@ -30,6 +34,9 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; + accessApprovalRequestDAL: Pick; + additionalPrivilegeDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -41,8 +48,11 @@ export const accessApprovalPolicyServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - userDAL -}: TSecretApprovalPolicyServiceFactoryDep) => { + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL, + accessApprovalRequestReviewerDAL +}: TAccessApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, actor, @@ -189,7 +199,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null }); return accessApprovalPolicies; }; @@ -326,7 +336,29 @@ export const accessApprovalPolicyServiceFactory = ({ ProjectPermissionSub.SecretApproval ); - await accessApprovalPolicyDAL.deleteById(policyId); + await accessApprovalPolicyDAL.transaction(async (tx) => { + await accessApprovalPolicyDAL.softDeleteById(policyId, tx); + const allAccessApprovalRequests = await accessApprovalRequestDAL.find({ policyId }); + + if (allAccessApprovalRequests.length) { + const accessApprovalRequestsIds = allAccessApprovalRequests.map((request) => request.id); + + const privilegeIdsArray = allAccessApprovalRequests + .map((request) => request.privilegeId) + .filter((id): id is string => id != null); + + if (privilegeIdsArray.length) { + await additionalPrivilegeDAL.delete({ $in: { id: privilegeIdsArray } }, tx); + } + + await accessApprovalRequestReviewerDAL.update( + { $in: { id: accessApprovalRequestsIds }, status: ApprovalStatus.PENDING }, + { status: ApprovalStatus.REJECTED }, + tx + ); + } + }); + return policy; }; @@ -356,7 +388,11 @@ export const accessApprovalPolicyServiceFactory = ({ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + const policies = await accessApprovalPolicyDAL.find({ + envId: environment.id, + projectId: project.id, + deletedAt: null + }); if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` }); return { count: policies.length }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 8784d05e2..c1ccedff7 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -61,7 +61,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), + db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -118,7 +119,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: doc.policyApprovals, secretPath: doc.policySecretPath, enforcementLevel: doc.policyEnforcementLevel, - envId: doc.policyEnvId + envId: doc.policyEnvId, + deletedAt: doc.policyDeletedAt }, requestedByUser: { userId: doc.requestedByUserId, @@ -141,7 +143,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId }), childrenMapper: [ { @@ -252,7 +254,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -271,7 +274,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + deletedAt: el.policyDeletedAt }, requestedByUser: { userId: el.requestedByUserId, @@ -363,6 +367,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .where(`${TableName.Environment}.projectId`, projectId) + .where(`${TableName.AccessApprovalPolicy}.deletedAt`, null) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 14accff41..b8475c446 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -130,6 +130,9 @@ export const accessApprovalRequestServiceFactory = ({ message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.` }); } + if (policy.deletedAt) { + throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); + } const approverIds: string[] = []; const approverGroupIds: string[] = []; @@ -309,6 +312,12 @@ export const accessApprovalRequestServiceFactory = ({ } const { policy } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index bb77660aa..6644b14b8 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -177,5 +177,10 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...secretApprovalPolicyOrm, findById, find }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await secretApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...secretApprovalPolicyOrm, findById, find, softDeleteById }; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index cb3452685..4e7bf6d15 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -11,6 +11,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; +import { RequestState } from "../secret-approval-request/secret-approval-request-types"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { @@ -34,6 +36,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; licenseService: Pick; + secretApprovalRequestDAL: Pick; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -44,7 +47,8 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyApproverDAL, projectEnvDAL, userDAL, - licenseService + licenseService, + secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createSecretApprovalPolicy = async ({ name, @@ -301,8 +305,16 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - await secretApprovalPolicyDAL.deleteById(secretPolicyId); - return sapPolicy; + const deletedPolicy = await secretApprovalPolicyDAL.transaction(async (tx) => { + await secretApprovalRequestDAL.update( + { policyId: secretPolicyId, status: RequestState.Open }, + { status: RequestState.Closed }, + tx + ); + const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx); + return updatedPolicy; + }); + return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment }; }; const getSecretApprovalPolicyByProjectId = async ({ @@ -321,7 +333,7 @@ export const secretApprovalPolicyServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const sapPolicies = await secretApprovalPolicyDAL.find({ projectId }); + const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; }; @@ -334,7 +346,7 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const policies = await secretApprovalPolicyDAL.find({ envId: env.id }); + const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null }); if (!policies.length) return; // this will filter policies either without scoped to secret path or the one that matches with secret path const policiesFilteredByPath = policies.filter( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 803b9464c..f842359bc 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -111,7 +111,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -147,7 +148,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, - envId: el.policyEnvId + envId: el.policyEnvId, + deletedAt: el.policyDeletedAt } }), childrenMapper: [ @@ -222,6 +224,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) .where({ projectId }) .andWhere( (bd) => @@ -229,6 +236,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) ) + .andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null)) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .count("status") diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a39f44fd6..e1c75b3f9 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -232,10 +232,10 @@ export const secretApprovalRequestServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( secretApprovalRequest.id ); - secrets = encrypedSecrets.map((el) => ({ + secrets = encryptedSecrets.map((el) => ({ ...el, secretKey: el.key, id: el.id, @@ -274,8 +274,8 @@ export const secretApprovalRequestServiceFactory = ({ })); } else { if (!botKey) throw new NotFoundError({ message: `Project bot key not found`, name: "BotKeyNotFound" }); // CLI depends on this error message. TODO(daniel): Make API check for name BotKeyNotFound instead of message - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); - secrets = encrypedSecrets.map((el) => ({ + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); + secrets = encryptedSecrets.map((el) => ({ ...el, ...decryptSecretWithBot(el, botKey), secret: el.secret @@ -323,6 +323,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -383,6 +389,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -433,6 +445,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy, folderId, projectId } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f07579bd..8dbae554f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -414,7 +414,8 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL, licenseService, - userDAL + userDAL, + secretApprovalRequestDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -994,7 +995,10 @@ export const registerRoutes = async ( projectEnvDAL, projectMembershipDAL, projectDAL, - userDAL + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + accessApprovalRequestReviewerDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 6df257590..bd6173d91 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; }; -export enum ApproverType{ +export enum ApproverType { User = "user", Group = "group" } -export type Approver ={ +export type Approver = { id: string; type: ApproverType; -} +}; export type TAccessApprovalRequest = { id: string; @@ -70,6 +70,7 @@ export type TAccessApprovalRequest = { secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; + deletedAt: Date | null; }; reviewers: { diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 93e906373..42e576f06 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -130,12 +130,14 @@ export const AccessApprovalRequest = ({ if (statusFilter === "open") return requests?.filter( (request) => + !request.policy.deletedAt && !request.isApproved && !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); if (statusFilter === "close") return requests?.filter( (request) => + request.policy.deletedAt || request.isApproved || request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); @@ -144,8 +146,6 @@ export const AccessApprovalRequest = ({ }, [requests, statusFilter, requestedByFilter, envFilter]); const generateRequestDetails = (request: TAccessApprovalRequest) => { - console.log(request); - const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; const isRejectedByAnyone = request.reviewers.some( ({ status }) => status === ApprovalStatus.REJECTED From 5d9b99bee7d467823ba9fba15eddce034924ca13 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 10 Dec 2024 07:47:36 +0400 Subject: [PATCH 69/70] Update NewProjectModal.tsx --- frontend/src/components/v2/projects/NewProjectModal.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx index 8f2cf79e8..1662b07ff 100644 --- a/frontend/src/components/v2/projects/NewProjectModal.tsx +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -36,7 +36,8 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, - useGetExternalKmsList + useGetExternalKmsList, + useGetUserWorkspaces } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; @@ -68,6 +69,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { const { permission } = useOrgPermission(); const { user } = useUser(); const createWs = useCreateWorkspace(); + const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); const addUsersToProject = useAddUserToWsNonE2EE(); const { subscription } = useSubscription(); @@ -137,8 +139,8 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { orgId: currentOrg.id }); } - // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. - await new Promise((resolve) => setTimeout(resolve, 2_000)); + + await refetchWorkspaces(); createNotification({ text: "Project created", type: "success" }); reset(); From e32716c2584c18c5bad59106d5be4c725ce28a47 Mon Sep 17 00:00:00 2001 From: McPizza Date: Tue, 10 Dec 2024 14:10:14 +0100 Subject: [PATCH 70/70] improvement: Better group member management (#2851) * improvement: Better org member management --- backend/src/ee/routes/v1/group-router.ts | 7 +- backend/src/ee/services/group/group-dal.ts | 31 ++- .../src/ee/services/group/group-service.ts | 14 +- backend/src/ee/services/group/group-types.ts | 6 + backend/src/lib/api-docs/constants.ts | 4 +- frontend/src/hooks/api/groups/index.tsx | 15 +- frontend/src/hooks/api/groups/mutations.tsx | 6 +- frontend/src/hooks/api/groups/queries.tsx | 38 ++-- frontend/src/hooks/api/groups/types.ts | 17 +- .../pages/org/[id]/groups/[groupId]/index.tsx | 19 ++ .../src/views/Org/GroupPage/GroupPage.tsx | 175 ++++++++++++++++ .../components/AddGroupMemberModal.tsx} | 65 +++--- .../components/GroupCreateUpdateModal.tsx | 192 +++++++++++++++++ .../components/GroupDetailsSection.tsx | 88 ++++++++ .../GroupMembersSection.tsx | 90 ++++++++ .../GroupMembersSection/GroupMembersTable.tsx | 195 ++++++++++++++++++ .../GroupMembershipRow.tsx | 53 +++++ .../components/GroupMembersSection/index.tsx | 1 + .../views/Org/GroupPage/components/index.tsx | 1 + frontend/src/views/Org/GroupPage/index.tsx | 1 + .../OrgGroupsSection/OrgGroupsSection.tsx | 2 - .../OrgGroupsSection/OrgGroupsTable.tsx | 50 ++--- 22 files changed, 974 insertions(+), 96 deletions(-) create mode 100644 frontend/src/pages/org/[id]/groups/[groupId]/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/GroupPage.tsx rename frontend/src/views/Org/{MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx => GroupPage/components/AddGroupMemberModal.tsx} (71%) create mode 100644 frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/index.tsx diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index b2f1762d1..67f955ecb 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { GROUPS } from "@app/lib/api-docs"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -151,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search) + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -164,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - isPartOfGroup: z.boolean() + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() }) ) .array(), diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f6113..fc38a2a9b 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { EFilterReturnedUsers } from "./group-types"; + export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { @@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => { offset = 0, limit, username, // depreciated in favor of search - search + search, + filter }: { orgId: string; groupId: string; @@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; }) => { try { const query = db @@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => { void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); } + switch (filter) { + case EFilterReturnedUsers.EXISTING_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + break; + case EFilterReturnedUsers.NON_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + const members = await query; return { members: members.map( - ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + ({ + email, + username: memberUsername, + firstName, + lastName, + userId, + groupId: memberGroupId, + joinedGroupAt + }) => ({ id: userId, email, username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId + isPartOfGroup: !!memberGroupId, + joinedGroupAt }) ), // @ts-expect-error col select is raw and not strongly typed diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7e7139a6b..68c48524b 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -222,7 +222,8 @@ export const groupServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - search + search, + filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -251,7 +252,8 @@ export const groupServiceFactory = ({ offset, limit, username, - search + search, + filter }); return { users: members, totalCount }; @@ -283,8 +285,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); const user = await userDAL.findOne({ username }); @@ -338,8 +340,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); const user = await userDAL.findOne({ username }); diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a6eb4782b..9424075ca 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -39,6 +39,7 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; } & TGenericPermission; export type TAddUserToGroupDTO = { @@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { projectBotDAL: Pick; tx?: Knex; }; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 518654da1..711837326 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -19,7 +19,9 @@ export const GROUPS = { offset: "The offset to start from. If you enter 10, it will start from the 10th user.", limit: "The number of users to return.", username: "The username to search for.", - search: "The text string that user email or name will be filtered by." + search: "The text string that user email or name will be filtered by.", + filterUsers: + "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, ADD_USER: { id: "The ID of the group to add the user to.", diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4..c23a55832 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,9 +1,8 @@ export { - useAddUserToGroup, - useCreateGroup, - useDeleteGroup, - useRemoveUserFromGroup, - useUpdateGroup} from "./mutations"; -export { - useListGroupUsers -} from "./queries"; \ No newline at end of file + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup +} from "./mutations"; +export { useGetGroupById, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 445ae10bc..2f5c5984c 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -56,8 +56,9 @@ export const useUpdateGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; @@ -70,8 +71,9 @@ export const useDeleteGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index b239b0a61..dc3791db7 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; + export const groupKeys = { + getGroupById: (groupId: string) => [{ groupId }, "group"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const, forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, @@ -10,22 +13,27 @@ export const groupKeys = { slug, offset, limit, - search + search, + filter }: { slug: string; offset: number; limit: number; search: string; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const + filter?: EFilterReturnedUsers; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const }; -type TUser = { - id: string; - email: string; - username: string; - firstName: string; - lastName: string; - isPartOfGroup: boolean; +export const useGetGroupById = (groupId: string) => { + return useQuery({ + enabled: Boolean(groupId), + queryKey: groupKeys.getGroupById(groupId), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/groups/${groupId}`); + + return { group: data }; + } + }); }; export const useListGroupUsers = ({ @@ -33,20 +41,23 @@ export const useListGroupUsers = ({ groupSlug, offset = 0, limit = 10, - search + search, + filter }: { id: string; groupSlug: string; offset: number; limit: number; search: string; + filter?: EFilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ slug: groupSlug, offset, limit, - search + search, + filter }), enabled: Boolean(groupSlug), keepPreviousData: true, @@ -54,10 +65,11 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search + search, + ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>( + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( `/api/v1/groups/${id}/users`, { params diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3f69b9a0e..6bc82b39e 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -11,7 +11,7 @@ export type TGroup = { name: string; slug: string; orgId: string; - createAt: string; + createdAt: string; updatedAt: string; role: string; }; @@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = { slug: string; orgId: string; }; + +export type TGroupUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; + joinedGroupAt: Date; +}; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx new file mode 100644 index 000000000..e193d9bd5 --- /dev/null +++ b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { GroupPage } from "@app/views/Org/GroupPage"; + +export default function Group() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +Group.requireAuth = true; diff --git a/frontend/src/views/Org/GroupPage/GroupPage.tsx b/frontend/src/views/Org/GroupPage/GroupPage.tsx new file mode 100644 index 000000000..acde15760 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/GroupPage.tsx @@ -0,0 +1,175 @@ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteGroup } from "@app/hooks/api"; +import { useGetGroupById } from "@app/hooks/api/groups/queries"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { TabSections } from "@app/views/Org/Types"; + +import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; +import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupDetailsSection } from "./components"; + +export const GroupPage = withPermission( + () => { + const router = useRouter(); + const groupId = router.query.groupId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data, isLoading } = useGetGroupById(groupId); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "groupCreateUpdate", + "deleteGroup", + "upgradePlan" + ] as const); + + const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { + try { + await deleteMutateAsync({ + id + }); + createNotification({ + text: `Successfully deleted the ${name} group`, + type: "success" + }); + router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the ${name} group`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + }; + + if (isLoading) return ; + + return ( +
+ {data && ( +
+ +
+

{data.group.name}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + disabled={!isAllowed} + > + Edit Group + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + id: groupId, + name: data.group.name + }); + }} + disabled={!isAllowed} + > + Delete Group + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx similarity index 71% rename from frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx rename to frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx index e7f38318a..ab81aa445 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx @@ -22,21 +22,22 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["groupMembers"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; + popUp: UsePopUpState<["addGroupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; }; -export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); - const popUpData = popUp?.groupMembers?.data as { + const popUpData = popUp?.addGroupMembers?.data as { groupId: string; slug: string; }; @@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { groupSlug: popUpData?.slug, offset, limit: perPage, - search: debouncedSearch + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS }); const { totalCount = 0 } = data ?? {}; @@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { setPage }); - const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); - const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - const handleAssignment = async (username: string, assign: boolean) => { + const handleAddMember = async (username: string) => { try { - if (!popUpData?.slug) return; - - if (assign) { - await assignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - } else { - await unassignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" }); + return; } + await addUserToGroupMutateAsync({ + groupId: popUpData.groupId, + username, + slug: popUpData.slug + }); + createNotification({ - text: `Successfully ${assign ? "assigned" : "removed"} user ${ - assign ? "to" : "from" - } group`, + text: "Successfully assigned user to the group", type: "success" }); } catch (err) { createNotification({ - text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, + text: "Failed to assign user to the group", type: "error" }); } @@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("groupMembers", isOpen); + handlePopUpToggle("addGroupMembers", isOpen); }} > - + setSearchMemberFilter(e.target.value)} @@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { {isLoading && } {!isLoading && - data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { + data?.users?.map(({ id, firstName, lastName, username }) => { return ( @@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { colorSchema="primary" variant="outline_bg" type="submit" - onClick={() => handleAssignment(username, !isPartOfGroup)} + onClick={() => handleAddMember(username)} > - {isPartOfGroup ? "Unassign" : "Assign"} + Assign ); }} @@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { )} {!isLoading && !data?.users?.length && ( )} diff --git a/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx new file mode 100644 index 000000000..39187f3cb --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx @@ -0,0 +1,192 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z + .string() + .min(5, "Slug must be at least 5 characters long") + .max(36, "Slug must be 36 characters or fewer"), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["groupCreateUpdate"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, + state?: boolean + ) => void; +}; + +export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) + }); + } else { + reset({ + name: "", + slug: "", + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + }); + } + }, [popUp?.groupCreateUpdate?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + id: group.groupId, + name, + slug, + role: role.slug || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role.slug || undefined + }); + } + handlePopUpToggle("groupCreateUpdate", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("groupCreateUpdate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx new file mode 100644 index 000000000..624cc7241 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx @@ -0,0 +1,88 @@ +import { faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetGroupById } from "@app/hooks/api/"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + groupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void; +}; + +export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => { + const { data, isLoading } = useGetGroupById(groupId); + + if (isLoading) return ; + + return data ? ( +
+
+

Group Details

+ + {(isAllowed) => { + return ( + + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + > + + + + ); + }} + +
+
+
+

Group ID

+
+

{data.group.id}

+ +
+
+
+

Name

+

{data.group.name}

+
+
+

Slug

+
+

{data.group.slug}

+ +
+
+
+

Organization Role

+

{data.group.role}

+
+
+

Created At

+

+ {new Date(data.group.createdAt).toLocaleString()} +

+
+
+
+ ) : ( +
+
+

Group data not found

+
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx new file mode 100644 index 000000000..08de6c724 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupMembersModal } from "../AddGroupMemberModal"; +import { GroupMembersTable } from "./GroupMembersTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupMembers", + "removeMemberFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); + const handleRemoveUserFromGroup = async (username: string) => { + try { + await removeUserFromGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${username} from the group`, + type: "success" + }); + + handlePopUpToggle("removeMemberFromGroup", false); + } catch (err) { + createNotification({ + text: `Failed to remove user ${username} from the group`, + type: "error" + }); + } + }; + + return ( +
+
+

Group Members

+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
+
+ +
+ + handlePopUpToggle("removeMemberFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const userData = popUp?.removeMemberFromGroup?.data as { + username: string; + id: string; + }; + + return handleRemoveUserFromGroup(userData.username); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx new file mode 100644 index 000000000..2423fd6d5 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupMembershipRow } from "./GroupMembershipRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>, + data?: {} + ) => void; +}; + +enum GroupMembersOrderBy { + Name = "name" +} + +export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + + const { data: groupMemberships, isLoading } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: EFilterReturnedUsers.EXISTING_MEMBERS + }); + + const filteredGroupMemberships = useMemo(() => { + return groupMemberships && groupMemberships?.users + ? groupMemberships?.users + ?.filter((membership) => { + const userSearchString = `${membership.firstName && membership.firstName} ${ + membership.lastName && membership.lastName + } ${membership.email && membership.email} ${ + membership.username && membership.username + }`; + return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); + }) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + const membershipOneComparisonString = membershipOne.firstName + ? membershipOne.firstName + : membershipOne.email; + + const membershipTwoComparisonString = membershipTwo.firstName + ? membershipTwo.firstName + : membershipTwo.email; + + const comparison = membershipOneComparisonString + .toLowerCase() + .localeCompare(membershipTwoComparisonString.toLowerCase()); + + return comparison; + }) + : []; + }, [groupMemberships, orderDirection, search]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships?.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
EmailAdded On +
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} + {!groupMemberships?.users.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx new file mode 100644 index 000000000..943a6574e --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx @@ -0,0 +1,53 @@ +import { faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TGroupUser } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + user: TGroupUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void; +}; + +export const GroupMembershipRow = ({ + user: { firstName, lastName, username, joinedGroupAt, email, id }, + handlePopUpOpen +}: Props) => { + return ( + + +

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + +

{email}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + {(isAllowed) => { + return ( + + handlePopUpOpen("removeMemberFromGroup", { username })} + variant="plain" + colorSchema="danger" + > + + + + ); + }} + + + + ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx new file mode 100644 index 000000000..70c696609 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx @@ -0,0 +1 @@ +export { GroupMembersSection } from "./GroupMembersSection"; diff --git a/frontend/src/views/Org/GroupPage/components/index.tsx b/frontend/src/views/Org/GroupPage/components/index.tsx new file mode 100644 index 000000000..003c47910 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/index.tsx @@ -0,0 +1 @@ +export { GroupDetailsSection } from "./GroupDetailsSection"; diff --git a/frontend/src/views/Org/GroupPage/index.tsx b/frontend/src/views/Org/GroupPage/index.tsx new file mode 100644 index 000000000..3dec23a1c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/index.tsx @@ -0,0 +1 @@ +export { GroupPage } from "./GroupPage"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index f72adf61f..9c3949150 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupsTable } from "./OrgGroupsTable"; @@ -78,7 +77,6 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> - { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); @@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { .slice(offset, perPage * page) .map(({ id, name, slug, role, customRole }) => { return ( - + router.push(`/org/${orgId}/groups/${id}`)} + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`org-group-${id}`} + > {name} {slug} @@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("groupMembers", { - groupId: id, - slug - }); - }} - disabled={!isAllowed} - > - Manage Users - - )} - - {(isAllowed) => ( { )} + + {(isAllowed) => ( + router.push(`/org/${orgId}/groups/${id}`)} + disabled={!isAllowed} + > + Manage Members + + )} +