- {userAvailableEnvs?.map(({ name, slug }, index) => {
+ {visibleEnvs?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount;
return (
@@ -388,12 +450,12 @@ export const SecretOverviewPage = () => {
)}
{isTableEmpty && !isTableLoading && (
-
+
{
colorSchema="primary"
size="md"
>
- Go to {userAvailableEnvs?.[0]?.name}
+ Go to {visibleEnvs?.[0]?.name}
@@ -414,13 +476,13 @@ export const SecretOverviewPage = () => {
))}
{!isTableLoading &&
- (userAvailableEnvs?.length > 0 ? (
+ (visibleEnvs?.length > 0 ? (
filteredSecretNames.map((key, index) => (
{
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
- environments={userAvailableEnvs}
+ environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
@@ -446,7 +508,7 @@ export const SecretOverviewPage = () => {
style={{ height: "45px" }}
/>
- {userAvailableEnvs.map(({ name, slug }) => (
+ {visibleEnvs.map(({ name, slug }) => (
Date: Sun, 18 Feb 2024 06:17:23 +0000
Subject: [PATCH 052/525] fix: upgrade aws-sdk from 2.1532.0 to 2.1545.0
Snyk has created this PR to upgrade aws-sdk from 2.1532.0 to 2.1545.0.
See this package in npm:
https://www.npmjs.com/package/aws-sdk
See this project in Snyk:
https://app.snyk.io/org/maidul98/project/35057e82-ed7d-4e19-ba4d-719a42135cd6?utm_source=github&utm_medium=referral&page=upgrade-pr
---
backend/package-lock.json | 30 +++++++++++++++++++++++++-----
backend/package.json | 2 +-
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/backend/package-lock.json b/backend/package-lock.json
index c153348f17..51b74a5465 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -29,7 +29,7 @@
"@ucast/mongo2js": "^1.3.4",
"ajv": "^8.12.0",
"argon2": "^0.31.2",
- "aws-sdk": "^2.1532.0",
+ "aws-sdk": "^2.1545.0",
"axios": "^1.6.4",
"axios-retry": "^4.0.0",
"bcrypt": "^5.1.1",
@@ -5151,9 +5151,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/aws-sdk": {
- "version": "2.1532.0",
- "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1532.0.tgz",
- "integrity": "sha512-4QVQs01LEAxo7UpSHlq/HaO+SJ1WrYF8W1otO2WhKpVRYXkSxXIgZgfYaK+sQ762XTtB6tSuD2ZS2HGsKNXVLw==",
+ "version": "2.1545.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1545.0.tgz",
+ "integrity": "sha512-iDUv6ksG7lTA0l/HlOgYdO6vfYFA1D2/JzAEXSdgKY0C901WgJqBtfs2CncOkCgDe2CjmlMuqciBzAfxCIiKFA==",
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
@@ -5164,7 +5164,7 @@
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
- "xml2js": "0.5.0"
+ "xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
@@ -5211,6 +5211,26 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/aws-sdk/node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/aws-sdk/node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/axios": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.4.tgz",
diff --git a/backend/package.json b/backend/package.json
index 4ec33aa457..0622cb82ca 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -90,7 +90,7 @@
"@ucast/mongo2js": "^1.3.4",
"ajv": "^8.12.0",
"argon2": "^0.31.2",
- "aws-sdk": "^2.1532.0",
+ "aws-sdk": "^2.1545.0",
"axios": "^1.6.4",
"axios-retry": "^4.0.0",
"bcrypt": "^5.1.1",
From 8864c811fe3b562a9c1d8e6af403643aa339fb39 Mon Sep 17 00:00:00 2001
From: Akhil Mohan
Date: Sun, 18 Feb 2024 22:47:13 +0530
Subject: [PATCH 053/525] feat: updated to reuse and run integration api test
before release
---
.../release-standalone-docker-img-postgres-offical.yml | 8 ++++++++
.github/workflows/run-backend-tests.yml | 5 ++---
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml
index 54f4f4fbe8..2e9044a9fa 100644
--- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml
+++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml
@@ -5,9 +5,17 @@ on:
- "infisical/v*.*.*-postgres"
jobs:
+ infisical-tests:
+ name: Run tests before deployment
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run backend api integration tests
+ # https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview
+ uses: ./.github/workflows/run-backend-tests.yml
infisical-standalone:
name: Build infisical standalone image postgres
runs-on: ubuntu-latest
+ needs: [infisical-tests]
steps:
- name: Extract version from tag
id: extract_version
diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml
index 2a805eab79..a283a538da 100644
--- a/.github/workflows/run-backend-tests.yml
+++ b/.github/workflows/run-backend-tests.yml
@@ -8,9 +8,8 @@ on:
- "!backend/README.md"
- "!backend/.*"
- "backend/.eslintrc.js"
- push:
- tags:
- - "infisical/v*.*.*-postgres"
+ workflow_call:
+
jobs:
check-be-pr:
name: Run integration test
From 678306b35071064d7ba85cdcf43f8557355a1638 Mon Sep 17 00:00:00 2001
From: Akhil Mohan
Date: Sun, 18 Feb 2024 22:51:10 +0530
Subject: [PATCH 054/525] feat: some name changes for better understanding on
testing
---
backend/e2e-test/routes/v1/identity.spec.ts | 4 ++--
backend/e2e-test/routes/v3/secrets.spec.ts | 8 ++++----
backend/src/db/seed-data.ts | 2 +-
backend/src/db/seeds/4-machine-identity.ts | 6 +++---
4 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/backend/e2e-test/routes/v1/identity.spec.ts b/backend/e2e-test/routes/v1/identity.spec.ts
index f30ba7b1e2..16ebb444e2 100644
--- a/backend/e2e-test/routes/v1/identity.spec.ts
+++ b/backend/e2e-test/routes/v1/identity.spec.ts
@@ -68,8 +68,8 @@ describe("Identity token secret ops", async () => {
method: "POST",
url: "/api/v1/auth/universal-auth/login",
body: {
- clientSecret: seedData1.machineIdentity.clientCred.secret,
- clientId: seedData1.machineIdentity.clientCred.id
+ clientSecret: seedData1.machineIdentity.clientCredentials.secret,
+ clientId: seedData1.machineIdentity.clientCredentials.id
}
});
expect(identityLogin.statusCode).toBe(200);
diff --git a/backend/e2e-test/routes/v3/secrets.spec.ts b/backend/e2e-test/routes/v3/secrets.spec.ts
index 89935d432d..02d239825c 100644
--- a/backend/e2e-test/routes/v3/secrets.spec.ts
+++ b/backend/e2e-test/routes/v3/secrets.spec.ts
@@ -31,7 +31,7 @@ describe("Secret V3 Router", async () => {
authorization: `Bearer ${jwtAuthToken}`
}
});
- const projectKeyEnc = JSON.parse(projectKeyRes.payload);
+ const projectKeyEncryptionDetails = JSON.parse(projectKeyRes.payload);
const userInfoRes = await testServer.inject({
method: "GET",
@@ -43,9 +43,9 @@ describe("Secret V3 Router", async () => {
const { user: userInfo } = JSON.parse(userInfoRes.payload);
const privateKey = await getUserPrivateKey(seedData1.password, userInfo);
projectKey = decryptAsymmetric({
- ciphertext: projectKeyEnc.encryptedKey,
- nonce: projectKeyEnc.nonce,
- publicKey: projectKeyEnc.sender.publicKey,
+ ciphertext: projectKeyEncryptionDetails.encryptedKey,
+ nonce: projectKeyEncryptionDetails.nonce,
+ publicKey: projectKeyEncryptionDetails.sender.publicKey,
privateKey
});
diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts
index 60038bdf88..8745d7f02e 100644
--- a/backend/src/db/seed-data.ts
+++ b/backend/src/db/seed-data.ts
@@ -35,7 +35,7 @@ export const seedData1 = {
machineIdentity: {
id: "88fa7aed-9288-401e-a4c9-fa9430be62a0",
name: "mac1",
- clientCred: {
+ clientCredentials: {
id: "3f6135db-f237-421d-af66-a8f4e80d443b",
secret: "da35a5a5a7b57f977a9a73394506e878a7175d06606df43dc93e1472b10cf339"
}
diff --git a/backend/src/db/seeds/4-machine-identity.ts b/backend/src/db/seeds/4-machine-identity.ts
index e51a81b3b8..149fba40a9 100644
--- a/backend/src/db/seeds/4-machine-identity.ts
+++ b/backend/src/db/seeds/4-machine-identity.ts
@@ -23,7 +23,7 @@ export async function seed(knex: Knex): Promise {
.insert([
{
identityId: seedData1.machineIdentity.id,
- clientId: seedData1.machineIdentity.clientCred.id,
+ clientId: seedData1.machineIdentity.clientCredentials.id,
clientSecretTrustedIps: JSON.stringify([
{
type: "ipv4",
@@ -54,7 +54,7 @@ export async function seed(knex: Knex): Promise {
}
])
.returning("*");
- const clientSecretHash = await bcrypt.hash(seedData1.machineIdentity.clientCred.secret, 10);
+ const clientSecretHash = await bcrypt.hash(seedData1.machineIdentity.clientCredentials.secret, 10);
await knex(TableName.IdentityUaClientSecret).insert([
{
identityUAId: identityUa[0].id,
@@ -62,7 +62,7 @@ export async function seed(knex: Knex): Promise {
clientSecretTTL: 0,
clientSecretNumUses: 0,
clientSecretNumUsesLimit: 0,
- clientSecretPrefix: seedData1.machineIdentity.clientCred.secret.slice(0, 4),
+ clientSecretPrefix: seedData1.machineIdentity.clientCredentials.secret.slice(0, 4),
clientSecretHash,
isClientSecretRevoked: false
}
From 7a65f8c83784891aa49fa98334e1109e3881d554 Mon Sep 17 00:00:00 2001
From: Tuan Dang
Date: Sun, 18 Feb 2024 18:50:23 -0800
Subject: [PATCH 055/525] Complete preliminary SCIM fns, add permissioning to
SCIM, add docs for SCIM
---
backend/src/@types/knex.d.ts | 6 +-
.../migrations/20240208234120_scim-token.ts | 9 +-
backend/src/db/schemas/organizations.ts | 3 +-
backend/src/db/schemas/scim-tokens.ts | 4 +-
backend/src/ee/routes/v1/scim-router.ts | 474 ++++++++++-------
.../ee/services/audit-log/audit-log-types.ts | 2 +-
.../src/ee/services/license/licence-fns.ts | 4 +-
.../src/ee/services/license/license-types.ts | 4 +-
.../ee/services/permission/org-permission.ts | 7 +
.../saml-config/saml-config-service.ts | 2 +-
backend/src/ee/services/scim/scim-fns.ts | 58 ++
backend/src/ee/services/scim/scim-service.ts | 503 ++++++++++++------
backend/src/ee/services/scim/scim-types.ts | 104 +++-
backend/src/lib/errors/index.ts | 19 +-
backend/src/lib/scim/fns.ts | 39 --
backend/src/lib/scim/index.ts | 4 -
backend/src/lib/scim/types.ts | 23 -
.../server/plugins/auth/inject-identity.ts | 2 +-
backend/src/server/plugins/error-handler.ts | 8 +-
backend/src/server/routes/index.ts | 20 +-
.../server/routes/v1/organization-router.ts | 3 +-
backend/src/services/org/org-dal.ts | 9 +-
backend/src/services/org/org-fns.ts | 21 +
backend/src/services/org/org-service.ts | 23 +-
backend/src/services/org/org-types.ts | 2 +-
backend/src/services/smtp/smtp-service.ts | 3 +-
.../templates/scimUserProvisioned.handlebars | 16 +
docs/documentation/platform/scim/azure.mdx | 74 +++
.../documentation/platform/scim/jumpcloud.mdx | 64 +++
docs/documentation/platform/scim/okta.mdx | 70 +++
docs/documentation/platform/scim/overview.mdx | 32 ++
docs/documentation/platform/sso/azure.mdx | 2 +-
docs/documentation/platform/sso/jumpcloud.mdx | 2 +-
docs/documentation/platform/sso/okta.mdx | 2 +-
docs/documentation/platform/sso/overview.mdx | 6 +-
.../platform/scim/azure/scim-azure-config.png | Bin 0 -> 233456 bytes
.../scim/azure/scim-azure-get-started.png | Bin 0 -> 263656 bytes
.../azure/scim-azure-provisioning-status.png | Bin 0 -> 246875 bytes
.../azure/scim-azure-select-user-mappings.png | Bin 0 -> 250404 bytes
.../azure/scim-azure-start-provisioning.png | Bin 0 -> 280659 bytes
.../scim/azure/scim-azure-user-mappings.png | Bin 0 -> 293919 bytes
.../jumpcloud/scim-jumpcloud-api-type.png | Bin 0 -> 525117 bytes
.../scim/jumpcloud/scim-jumpcloud-config.png | Bin 0 -> 448856 bytes
.../scim-jumpcloud-test-connection.png | Bin 0 -> 450020 bytes
.../scim/okta/scim-okta-app-settings.png | Bin 0 -> 376938 bytes
.../platform/scim/okta/scim-okta-auth.png | Bin 0 -> 296106 bytes
.../platform/scim/okta/scim-okta-config.png | Bin 0 -> 315196 bytes
.../okta/scim-okta-enable-provisioning.png | Bin 0 -> 340233 bytes
.../platform/scim/okta/scim-okta-test.png | Bin 0 -> 296184 bytes
docs/images/platform/scim/scim-copy-token.png | Bin 0 -> 541939 bytes
.../platform/scim/scim-create-token.png | Bin 0 -> 464488 bytes
.../scim/scim-enable-provisioning.png | Bin 0 -> 632837 bytes
docs/mint.json | 9 +
.../src/context/OrgPermissionContext/types.ts | 2 +
.../src/hooks/api/organization/queries.tsx | 2 +
frontend/src/hooks/api/organization/types.ts | 2 +
frontend/src/hooks/api/scim/mutations.tsx | 4 +-
frontend/src/hooks/api/scim/types.ts | 4 +-
.../OrgAuthTab/OrgGeneralAuthSection.tsx | 24 +-
.../components/OrgAuthTab/OrgSCIMSection.tsx | 95 ++--
.../components/OrgAuthTab/ScimTokenModal.tsx | 47 +-
61 files changed, 1260 insertions(+), 553 deletions(-)
create mode 100644 backend/src/ee/services/scim/scim-fns.ts
delete mode 100644 backend/src/lib/scim/fns.ts
delete mode 100644 backend/src/lib/scim/index.ts
delete mode 100644 backend/src/lib/scim/types.ts
create mode 100644 backend/src/services/org/org-fns.ts
create mode 100644 backend/src/services/smtp/templates/scimUserProvisioned.handlebars
create mode 100644 docs/documentation/platform/scim/azure.mdx
create mode 100644 docs/documentation/platform/scim/jumpcloud.mdx
create mode 100644 docs/documentation/platform/scim/okta.mdx
create mode 100644 docs/documentation/platform/scim/overview.mdx
create mode 100644 docs/images/platform/scim/azure/scim-azure-config.png
create mode 100644 docs/images/platform/scim/azure/scim-azure-get-started.png
create mode 100644 docs/images/platform/scim/azure/scim-azure-provisioning-status.png
create mode 100644 docs/images/platform/scim/azure/scim-azure-select-user-mappings.png
create mode 100644 docs/images/platform/scim/azure/scim-azure-start-provisioning.png
create mode 100644 docs/images/platform/scim/azure/scim-azure-user-mappings.png
create mode 100644 docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png
create mode 100644 docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png
create mode 100644 docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png
create mode 100644 docs/images/platform/scim/okta/scim-okta-app-settings.png
create mode 100644 docs/images/platform/scim/okta/scim-okta-auth.png
create mode 100644 docs/images/platform/scim/okta/scim-okta-config.png
create mode 100644 docs/images/platform/scim/okta/scim-okta-enable-provisioning.png
create mode 100644 docs/images/platform/scim/okta/scim-okta-test.png
create mode 100644 docs/images/platform/scim/scim-copy-token.png
create mode 100644 docs/images/platform/scim/scim-create-token.png
create mode 100644 docs/images/platform/scim/scim-enable-provisioning.png
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 1b4e82176f..5b59615581 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -265,11 +265,7 @@ declare module "knex/types/tables" {
TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate
>;
- [TableName.ScimToken]: Knex.CompositeTableType<
- TScimTokens,
- TScimTokensInsert,
- TScimTokensUpdate
- >;
+ [TableName.ScimToken]: Knex.CompositeTableType;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies,
TSecretApprovalPoliciesInsert,
diff --git a/backend/src/db/migrations/20240208234120_scim-token.ts b/backend/src/db/migrations/20240208234120_scim-token.ts
index 92364dba2e..28121362a4 100644
--- a/backend/src/db/migrations/20240208234120_scim-token.ts
+++ b/backend/src/db/migrations/20240208234120_scim-token.ts
@@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise {
if (!(await knex.schema.hasTable(TableName.ScimToken))) {
await knex.schema.createTable(TableName.ScimToken, (t) => {
t.string("id", 36).primary().defaultTo(knex.fn.uuid());
- t.bigInteger("ttl").defaultTo(15552000).notNullable(); // 180 days second
+ t.bigInteger("ttlDays").defaultTo(365).notNullable();
t.string("description").notNullable();
t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
@@ -15,10 +15,17 @@ export async function up(knex: Knex): Promise {
});
}
+ await knex.schema.alterTable(TableName.Organization, (t) => {
+ t.boolean("scimEnabled").defaultTo(false);
+ });
+
await createOnUpdateTrigger(knex, TableName.ScimToken);
}
export async function down(knex: Knex): Promise {
await knex.schema.dropTableIfExists(TableName.ScimToken);
await dropOnUpdateTrigger(knex, TableName.ScimToken);
+ await knex.schema.alterTable(TableName.Organization, (t) => {
+ t.dropColumn("scimEnabled");
+ });
}
diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts
index 087a1b7e01..a91b93ea51 100644
--- a/backend/src/db/schemas/organizations.ts
+++ b/backend/src/db/schemas/organizations.ts
@@ -14,7 +14,8 @@ export const OrganizationsSchema = z.object({
slug: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
- authEnforced: z.boolean().default(false).nullable().optional()
+ authEnforced: z.boolean().default(false).nullable().optional(),
+ scimEnabled: z.boolean().default(false).nullable().optional()
});
export type TOrganizations = z.infer;
diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts
index ac391ca917..593e4f7d9b 100644
--- a/backend/src/db/schemas/scim-tokens.ts
+++ b/backend/src/db/schemas/scim-tokens.ts
@@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models";
export const ScimTokensSchema = z.object({
id: z.string(),
- ttl: z.coerce.number().default(15552000),
+ ttlDays: z.coerce.number().default(365),
description: z.string(),
orgId: z.string().uuid(),
createdAt: z.date(),
- updatedAt: z.date(),
+ updatedAt: z.date()
});
export type TScimTokens = z.infer;
diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts
index 2e2edac43e..6116599f12 100644
--- a/backend/src/ee/routes/v1/scim-router.ts
+++ b/backend/src/ee/routes/v1/scim-router.ts
@@ -1,195 +1,19 @@
-import jwt from "jsonwebtoken";
import { z } from "zod";
+
import { ScimTokensSchema } from "@app/db/schemas";
-
-import { getConfig } from "@app/lib/config/env";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
-import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type";
-
-
+import { AuthMode } from "@app/services/auth/auth-type";
export const registerScimRouter = async (server: FastifyZodProvider) => {
- server.route({
- url: "/",
- method: "GET",
- schema: {
- params: z.object({}),
- response: {
- 200: z.object({})
- }
- },
- // onRequest: verifyAuth([AuthMode.JWT]),
- handler: async () => {
- return {
- hello: "world"
- };
- }
- });
+ server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
+ try {
+ const strBody = body instanceof Buffer ? body.toString() : body;
- server.route({
- url: "/Users",
- method: "GET",
- schema: {
- querystring: z.object({
- startIndex: z.coerce.number().default(1),
- count: z.coerce.number().default(20),
- filter: z.string().trim().optional()
- }),
- response: {
- 200: z.object({ // TODO: audit the response
- Resources: z.array(z.object({
- id: z.string().trim(),
- userName: z.string().trim(),
- name: z.object({
- familyName: z.string().trim(),
- givenName: z.string().trim()
- }),
- emails: z.array(z.object({
- primary: z.boolean(),
- value: z.string().email(),
- type: z.string().trim()
- })),
- displayName: z.string().trim(),
- active: z.boolean()
- })),
- itemsPerPage: z.number(),
- schemas: z.array(z.string()),
- startIndex: z.number(),
- totalResults: z.number(),
- })
- }
- },
- onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
- handler: async (req) => {
- const res = await req.server.services.scim.listUsers({
- offset: req.query.startIndex,
- limit: req.query.count,
- filter: req.query.filter
- });
- return res;
- }
- });
-
- server.route({
- url: "/Users/:userId",
- method: "GET",
- schema: {
- params: z.object({
- userId: z.string().trim()
- }),
- response: {
- 201: z.object({
- schemas: z.array(z.string()),
- id: z.string().trim(),
- userName: z.string().trim(),
- name: z.object({
- familyName: z.string().trim(),
- givenName: z.string().trim()
- }),
- emails: z.array(z.object({
- primary: z.boolean(),
- value: z.string().email(),
- type: z.string().trim()
- })),
- displayName: z.string().trim(),
- active: z.boolean()
- })
- }
- },
- onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
- handler: async (req) => {
- const res = await req.server.services.scim.getUser(req.params.userId);
- return res;
- }
- });
-
- server.route({
- url: "/Users",
- method: "POST",
- schema: {
- body: z.object({
- schemas: z.array(z.string()),
- userName: z.string().trim(),
- name: z.object({
- familyName: z.string().trim(),
- givenName: z.string().trim()
- }),
- emails: z.array(z.object({
- primary: z.boolean(),
- value: z.string().email(),
- type: z.string().trim()
- })),
- displayName: z.string().trim(),
- // locale: z.string().trim(),
- // externalId: z.string().trim(),
- // groups: z.array(z.object({
- // value: z.string().trim()
- // })),
- // password: z.string().trim(),
- active: z.boolean()
- }),
- response: {
- 200: z.object({
- schemas: z.array(z.string()),
- id: z.string().trim(),
- userName: z.string().trim(),
- name: z.object({
- familyName: z.string().trim(),
- givenName: z.string().trim()
- }),
- emails: z.array(z.object({
- primary: z.boolean(),
- value: z.string().email(),
- type: z.string().trim()
- })),
- displayName: z.string().trim(),
- active: z.boolean()
- })
- }
- },
- onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
- handler: async (req, reply) => {
- const user = await req.server.services.scim.createUser({
- email: req.body.emails[0].value,
- firstName: req.body.name.givenName,
- lastName: req.body.name.familyName,
- orgId: req.permission.orgId as string
- });
-
- reply.code(201);
- return user;
- }
- });
-
- server.route({
- url: "/Users/:userId",
- method: "PATCH",
- schema: {
- body: z.object({}),
- response: {
- 200: z.object({})
- }
- },
- onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
- handler: async (req) => {
- // TODO: update a user's attr
- return {};
- }
- });
-
- server.route({
- url: "/Users/:userId",
- method: "PUT",
- schema: {
- body: z.object({}),
- response: {
- 200: z.object({})
- }
- },
- onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
- handler: async (req) => {
- // TODO: update a user's profile
- return {};
+ const json: unknown = JSON.parse(strBody); // TODO: update
+ done(null, json);
+ } catch (err) {
+ const error = err as Error;
+ done(error, undefined);
}
});
@@ -201,7 +25,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({
organizationId: z.string().trim(),
description: z.string().trim().default(""),
- ttl: z.number().min(0).default(0)
+ ttlDays: z.number().min(0).default(0)
}),
response: {
200: z.object({
@@ -211,9 +35,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
handler: async (req) => {
const { scimToken } = await server.services.scim.createScimToken({
- organizationId: req.body.organizationId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ orgId: req.body.organizationId,
description: req.body.description,
- ttl: req.body.ttl
+ ttlDays: req.body.ttlDays
});
return { scimToken };
@@ -235,7 +62,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
- const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId);
+ const scimTokens = await server.services.scim.listScimTokens({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ orgId: req.query.organizationId
+ });
+
return { scimTokens };
}
});
@@ -255,8 +88,267 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
- const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId);
+ const scimToken = await server.services.scim.deleteScimToken({
+ scimTokenId: req.params.scimTokenId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId
+ });
+
return { scimToken };
}
});
+
+ // SCIM server endpoints
+ server.route({
+ url: "/Users",
+ method: "GET",
+ schema: {
+ querystring: z.object({
+ startIndex: z.coerce.number().default(1),
+ count: z.coerce.number().default(20),
+ filter: z.string().trim().optional()
+ }),
+ response: {
+ 200: z.object({
+ Resources: z.array(
+ z.object({
+ id: z.string().trim(),
+ userName: z.string().trim(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ emails: z.array(
+ z.object({
+ primary: z.boolean(),
+ value: z.string().email(),
+ type: z.string().trim()
+ })
+ ),
+ displayName: z.string().trim(),
+ active: z.boolean()
+ })
+ ),
+ itemsPerPage: z.number(),
+ schemas: z.array(z.string()),
+ startIndex: z.number(),
+ totalResults: z.number()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ handler: async (req) => {
+ const users = await req.server.services.scim.listScimUsers({
+ offset: req.query.startIndex,
+ limit: req.query.count,
+ filter: req.query.filter,
+ orgId: req.permission.orgId as string
+ });
+ return users;
+ }
+ });
+
+ server.route({
+ url: "/Users/:userId",
+ method: "GET",
+ schema: {
+ params: z.object({
+ userId: z.string().trim()
+ }),
+ response: {
+ 201: z.object({
+ schemas: z.array(z.string()),
+ id: z.string().trim(),
+ userName: z.string().trim(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ emails: z.array(
+ z.object({
+ primary: z.boolean(),
+ value: z.string().email(),
+ type: z.string().trim()
+ })
+ ),
+ displayName: z.string().trim(),
+ active: z.boolean()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ handler: async (req) => {
+ const user = await req.server.services.scim.getScimUser({
+ userId: req.params.userId,
+ orgId: req.permission.orgId as string
+ });
+ return user;
+ }
+ });
+
+ server.route({
+ url: "/Users",
+ method: "POST",
+ schema: {
+ body: z.object({
+ schemas: z.array(z.string()),
+ userName: z.string().trim().email(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ // emails: z.array( // optional?
+ // z.object({
+ // primary: z.boolean(),
+ // value: z.string().email(),
+ // type: z.string().trim()
+ // })
+ // ),
+ // displayName: z.string().trim(),
+ active: z.boolean()
+ }),
+ response: {
+ 200: z.object({
+ schemas: z.array(z.string()),
+ id: z.string().trim(),
+ userName: z.string().trim().email(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ emails: z.array(
+ z.object({
+ primary: z.boolean(),
+ value: z.string().email(),
+ type: z.string().trim()
+ })
+ ),
+ displayName: z.string().trim(),
+ active: z.boolean()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ handler: async (req) => {
+ const user = await req.server.services.scim.createScimUser({
+ email: req.body.userName,
+ firstName: req.body.name.givenName,
+ lastName: req.body.name.familyName,
+ orgId: req.permission.orgId as string
+ });
+
+ return user;
+ }
+ });
+
+ server.route({
+ url: "/Users/:userId",
+ method: "PATCH",
+ schema: {
+ params: z.object({
+ userId: z.string().trim()
+ }),
+ body: z.object({
+ schemas: z.array(z.string()),
+ Operations: z.array(
+ z.object({
+ op: z.string().trim(),
+ path: z.string().trim().optional(),
+ value: z.union([
+ z.object({
+ active: z.boolean()
+ }),
+ z.string().trim()
+ ])
+ })
+ )
+ }),
+ response: {
+ 200: z.object({})
+ }
+ },
+ onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ handler: async (req) => {
+ const user = await req.server.services.scim.updateScimUser({
+ userId: req.params.userId,
+ orgId: req.permission.orgId as string,
+ operations: req.body.Operations
+ });
+ return user;
+ }
+ });
+
+ server.route({
+ url: "/Users/:userId",
+ method: "PUT",
+ schema: {
+ params: z.object({
+ userId: z.string().trim()
+ }),
+ body: z.object({
+ schemas: z.array(z.string()),
+ id: z.string().trim(),
+ userName: z.string().trim(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ // emails: z.array(
+ // z.object({
+ // primary: z.boolean(),
+ // value: z.string().email(),
+ // type: z.string().trim()
+ // })
+ // ),
+ displayName: z.string().trim(),
+ active: z.boolean()
+ }),
+ response: {
+ 200: z.object({
+ schemas: z.array(z.string()),
+ id: z.string().trim(),
+ userName: z.string().trim(),
+ name: z.object({
+ familyName: z.string().trim(),
+ givenName: z.string().trim()
+ }),
+ emails: z.array(
+ z.object({
+ primary: z.boolean(),
+ value: z.string().email(),
+ type: z.string().trim()
+ })
+ ),
+ displayName: z.string().trim(),
+ active: z.boolean()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ handler: async (req) => {
+ const user = await req.server.services.scim.replaceScimUser({
+ userId: req.params.userId,
+ orgId: req.permission.orgId as string,
+ active: req.body.active
+ });
+ return user;
+ }
+ });
+
+ // server.route({
+ // url: "/Users/:userId",
+ // method: "DELETE",
+ // schema: {
+ // body: z.object({}),
+ // response: {
+ // 200: z.object({})
+ // }
+ // },
+ // onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
+ // handler: () => {
+ // // TODO: update a user's profile
+ // return {};
+ // }
+ // });
};
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index dfa98b0e41..4ed86b2925 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
export type TCreateAuditLogDTO = {
event: Event;
- actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor;
+ actor: UserActor | IdentityActor | ServiceActor;
orgId?: string;
projectId?: string;
} & BaseAuthData;
diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts
index d41e457782..9b1d4c203d 100644
--- a/backend/src/ee/services/license/licence-fns.ts
+++ b/backend/src/ee/services/license/licence-fns.ts
@@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
customAlerts: false,
auditLogs: false,
auditLogsRetentionDays: 0,
- samlSSO: true,
- scim: true,
+ samlSSO: false,
+ scim: false,
status: null,
trial_end: null,
has_used_trial: true,
diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts
index bc83f43f0c..3547144894 100644
--- a/backend/src/ee/services/license/license-types.ts
+++ b/backend/src/ee/services/license/license-types.ts
@@ -24,8 +24,8 @@ export type TFeatureSet = {
customAlerts: false;
auditLogs: false;
auditLogsRetentionDays: 0;
- samlSSO: true;
- scim: true;
+ samlSSO: false;
+ scim: false;
status: null;
trial_end: null;
has_used_trial: true;
diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts
index cc18af8aea..e527f1a4a8 100644
--- a/backend/src/ee/services/permission/org-permission.ts
+++ b/backend/src/ee/services/permission/org-permission.ts
@@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
Settings = "settings",
IncidentAccount = "incident-contact",
Sso = "sso",
+ Scim = "scim",
Billing = "billing",
SecretScanning = "secret-scanning",
Identity = "identity"
@@ -29,6 +30,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
+ | [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
@@ -69,6 +71,11 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso);
+ can(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
+ can(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
+ can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
+ can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
+
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts
index 767729179a..ad33051fb4 100644
--- a/backend/src/ee/services/saml-config/saml-config-service.ts
+++ b/backend/src/ee/services/saml-config/saml-config-service.ts
@@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({
updateQuery.certTag = certTag;
}
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
- await orgDAL.updateById(orgId, { authEnforced: false });
+ await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false });
return ssoConfig;
};
diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts
new file mode 100644
index 0000000000..b5f35c9ee2
--- /dev/null
+++ b/backend/src/ee/services/scim/scim-fns.ts
@@ -0,0 +1,58 @@
+import { TListScimUsers, TScimUser } from "./scim-types";
+
+export const buildScimUserList = ({
+ scimUsers,
+ offset,
+ limit
+}: {
+ scimUsers: TScimUser[];
+ offset: number;
+ limit: number;
+}): TListScimUsers => {
+ return {
+ Resources: scimUsers,
+ itemsPerPage: limit,
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ startIndex: offset,
+ totalResults: scimUsers.length
+ };
+};
+
+export const buildScimUser = ({
+ userId,
+ firstName,
+ lastName,
+ email,
+ active
+}: {
+ userId: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ active: boolean;
+}): TScimUser => {
+ return {
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ id: userId,
+ userName: email,
+ displayName: `${firstName} ${lastName}`,
+ name: {
+ givenName: firstName,
+ middleName: null,
+ familyName: lastName
+ },
+ emails: [
+ {
+ primary: true,
+ value: email,
+ type: "work"
+ }
+ ],
+ active,
+ groups: [],
+ meta: {
+ resourceType: "User",
+ location: null
+ }
+ };
+};
diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts
index 12eb2e94a6..b6f6d9769e 100644
--- a/backend/src/ee/services/scim/scim-service.ts
+++ b/backend/src/ee/services/scim/scim-service.ts
@@ -1,61 +1,71 @@
+import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";
-import {
- OrgMembershipRole,
- OrgMembershipStatus
-} from "@app/db/schemas";
+import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
-import { TUserDALFactory } from "@app/services/user/user-dal";
-import { TOrgDALFactory } from "@app/services/org/org-dal";
-import { TPermissionServiceFactory } from "../permission/permission-service";
-import { TLicenseServiceFactory } from "../license/license-service";
import { getConfig } from "@app/lib/config/env";
+import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
+import { TOrgPermission } from "@app/lib/types";
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
+import { TOrgDALFactory } from "@app/services/org/org-dal";
+import { deleteOrgMembership } from "@app/services/org/org-fns";
+import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
+import { TUserDALFactory } from "@app/services/user/user-dal";
+
+import { TLicenseServiceFactory } from "../license/license-service";
+import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
+import { TPermissionServiceFactory } from "../permission/permission-service";
+import { buildScimUser, buildScimUserList } from "./scim-fns";
import {
TCreateScimTokenDTO,
- TListScimUsersDTO,
- TListScimUsersRes,
TCreateScimUserDTO,
- TScimTokenJwtPayload
+ TDeleteScimTokenDTO,
+ TGetScimUserDTO,
+ TListScimUsers,
+ TListScimUsersDTO,
+ TReplaceScimUserDTO,
+ TScimTokenJwtPayload,
+ TUpdateScimUserDTO
} from "./scim-types";
-import {
- createScimUser,
- TScimUser,
-} from "@app/lib/scim";
-import { UnauthorizedError, ScimRequestError } from "@app/lib/errors";
type TScimServiceFactoryDep = {
- permissionService: Pick;
+ // TODO: pick types
scimDAL: TScimDALFactory; // TODO: pick
userDAL: TUserDALFactory; // TODO: pick
orgDAL: TOrgDALFactory; // TODO: pick
licenseService: Pick;
+ permissionService: Pick;
+ smtpService: TSmtpService;
};
export type TScimServiceFactory = ReturnType;
-export const scimServiceFactory = ({
+export const scimServiceFactory = ({
licenseService,
scimDAL,
userDAL,
orgDAL,
- permissionService
+ permissionService,
+ smtpService
}: TScimServiceFactoryDep) => {
- const createScimToken = async ({
- organizationId,
- description,
- ttl
- }: TCreateScimTokenDTO) => {
+ const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => {
+ const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
+
+ const plan = await licenseService.getPlan(orgId);
+ if (!plan.scim)
+ throw new BadRequestError({
+ message: "Failed to create a SCIM token due to plan restriction. Upgrade plan to create a SCIM token."
+ });
+
const appCfg = getConfig();
-
- // TODO: permission stuff
const scimTokenData = await scimDAL.create({
- orgId: organizationId,
+ orgId,
description,
- ttl
+ ttlDays
});
-
+
const scimToken = jwt.sign(
{
scimTokenId: scimTokenData.id,
@@ -63,167 +73,346 @@ export const scimServiceFactory = ({
},
appCfg.AUTH_SECRET
);
-
- return { scimToken }
- }
- const getScimTokens = async (organizationId: string) => {
- const scimTokens = await scimDAL.find({ orgId: organizationId });
+ return { scimToken };
+ };
+
+ const listScimTokens = async ({ actor, actorId, actorOrgId, orgId }: TOrgPermission) => {
+ const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
+
+ const plan = await licenseService.getPlan(orgId);
+ if (!plan.scim)
+ throw new BadRequestError({
+ message: "Failed to get SCIM tokens due to plan restriction. Upgrade plan to get SCIM tokens."
+ });
+
+ const scimTokens = await scimDAL.find({ orgId });
return scimTokens;
- }
-
- const deleteScimToken = async (scimTokenId: string) => {
- const scimToken = await scimDAL.deleteById(scimTokenId);
- return scimToken;
- }
-
- // scim server endpoints
+ };
+
+ const deleteScimToken = async ({ scimTokenId, actor, actorId, actorOrgId }: TDeleteScimTokenDTO) => {
+ let scimToken = await scimDAL.findById(scimTokenId);
+ if (!scimToken) throw new BadRequestError({ message: "Failed to find SCIM token to delete" });
+
+ const { permission } = await permissionService.getOrgPermission(actor, actorId, scimToken.orgId, actorOrgId);
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
+
+ const plan = await licenseService.getPlan(scimToken.orgId);
+ if (!plan.scim)
+ throw new BadRequestError({
+ message: "Failed to delete the SCIM token due to plan restriction. Upgrade plan to delete the SCIM token."
+ });
+
+ scimToken = await scimDAL.deleteById(scimTokenId);
+
+ return scimToken;
+ };
+
+ // SCIM server endpoints
+ const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => {
+ const org = await orgDAL.findById(orgId);
+
+ if (!org.scimEnabled)
+ throw new ScimRequestError({
+ detail: "SCIM is disabled for the organization",
+ status: 403
+ });
+
+ const parseFilter = (filterToParse: string | undefined) => {
+ if (!filterToParse) return {};
+ const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim());
- const listUsers = async ({
- offset,
- limit,
- filter
- }: TListScimUsersDTO): Promise => {
-
- const parseFilter = (filter: string | undefined) => {
- if (!filter) return {};
- const [parsedName, parsedValue] = filter.split("eq").map(s => s.trim());
-
let attributeName = parsedName;
- if (parsedName === "userName") { // note
+ if (parsedName === "userName") {
attributeName = "email";
}
-
+
return { [attributeName]: parsedValue };
};
-
+
const findOpts = {
...(offset && { offset }),
- ...(limit && { limit }),
+ ...(limit && { limit })
};
-
- const users = await userDAL.find(parseFilter(filter), findOpts);
-
- let resources: TScimUser[] = [];
-
- let scimResource: TListScimUsersRes = { // note: type
- Resources: [],
- itemsPerPage: limit,
- schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
- startIndex: offset,
- totalResults: users.length
- };
-
- users.forEach((user) => {
- let scimUser = createScimUser({
- userId: user.id,
- firstName: user.firstName as string,
- lastName: user.lastName as string,
- email: user.email
- });
- resources.push(scimUser);
+
+ const users = await orgDAL.findMembership(
+ {
+ orgId,
+ ...parseFilter(filter)
+ },
+ findOpts
+ );
+
+ const scimUsers = users.map(({ userId, firstName, lastName, email }) =>
+ buildScimUser({
+ userId: userId ?? "",
+ firstName: firstName ?? "",
+ lastName: lastName ?? "",
+ email,
+ active: true
+ })
+ );
+
+ return buildScimUserList({
+ scimUsers,
+ offset,
+ limit
});
+ };
- scimResource.Resources = resources;
-
- return scimResource;
- }
-
- const getUser = async (userId: string) => {
- // TODO: check out SCIM-specific errors
-
- let user;
- try {
- user = await userDAL.findById(userId);
- } catch (error) {
+ const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
+ const [membership] = await orgDAL
+ .findMembership({
+ userId,
+ orgId
+ })
+ .catch(() => {
+ throw new ScimRequestError({
+ detail: "User not found",
+ status: 404
+ });
+ });
- interface PostgresError extends Error {
- error: {
- code: string;
- }
- }
-
- const dbError = error as PostgresError;
-
- if (dbError.error.code === "22P02") throw new ScimRequestError({
+ if (!membership)
+ throw new ScimRequestError({
detail: "User not found",
status: 404
});
-
- throw error;
- }
-
- if (!user) throw new ScimRequestError({
- detail: "User not found",
- status: 404
+
+ if (!membership.scimEnabled)
+ throw new ScimRequestError({
+ detail: "SCIM is disabled for the organization",
+ status: 403
+ });
+
+ return buildScimUser({
+ userId: membership.userId as string,
+ firstName: membership.firstName as string,
+ lastName: membership.lastName as string,
+ email: membership.email,
+ active: true
});
-
- return createScimUser({
- userId: user.id,
- firstName: user.firstName as string,
- lastName: user.lastName as string,
- email: user.email
- });
- }
-
- const createUser = async ({
- firstName,
- lastName,
- email,
- orgId
- }: TCreateScimUserDTO) => {
+ };
+
+ const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => {
+ const org = await orgDAL.findById(orgId);
+
+ if (!org)
+ throw new ScimRequestError({
+ detail: "Organization not found",
+ status: 404
+ });
+
+ if (!org.scimEnabled)
+ throw new ScimRequestError({
+ detail: "SCIM is disabled for the organization",
+ status: 403
+ });
+
let user = await userDAL.findOne({
email
});
-
- if (user) throw new ScimRequestError({
- detail: "User already exists in the database",
- status: 409
+
+ if (user) {
+ await userDAL.transaction(async (tx) => {
+ const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
+ if (orgMembership)
+ throw new ScimRequestError({
+ detail: "User already exists in the database",
+ status: 409
+ });
+
+ if (!orgMembership) {
+ await orgDAL.createMembership(
+ {
+ userId: user.id,
+ orgId,
+ inviteEmail: email,
+ role: OrgMembershipRole.Member,
+ status: OrgMembershipStatus.Invited
+ },
+ tx
+ );
+ }
+ });
+ } else {
+ user = await userDAL.transaction(async (tx) => {
+ const newUser = await userDAL.create(
+ {
+ email,
+ firstName,
+ lastName,
+ authMethods: [AuthMethod.EMAIL]
+ },
+ tx
+ );
+
+ await orgDAL.createMembership(
+ {
+ inviteEmail: email,
+ orgId,
+ userId: newUser.id,
+ role: OrgMembershipRole.Member,
+ status: OrgMembershipStatus.Invited
+ },
+ tx
+ );
+ return newUser;
+ });
+ }
+
+ const appCfg = getConfig();
+ await smtpService.sendMail({
+ template: SmtpTemplates.ScimUserProvisioned,
+ subjectLine: "Infisical organization invitation",
+ recipients: [email],
+ substitutions: {
+ organizationName: org.name,
+ callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
+ }
});
- user = await userDAL.transaction(async (tx) => {
- const newUser = await userDAL.create(
- {
- email,
- firstName,
- lastName,
- authMethods: [AuthMethod.EMAIL]
- },
- tx
- );
- await orgDAL.createMembership({
- inviteEmail: email,
- orgId,
- role: OrgMembershipRole.Member,
- status: OrgMembershipStatus.Invited
- });
- return newUser;
- });
-
- return createScimUser({
+ return buildScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
- email: user.email
+ email: user.email,
+ active: true
});
- }
-
+ };
+
+ const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => {
+ const [membership] = await orgDAL
+ .findMembership({
+ userId,
+ orgId
+ })
+ .catch(() => {
+ throw new ScimRequestError({
+ detail: "User not found",
+ status: 404
+ });
+ });
+
+ if (!membership)
+ throw new ScimRequestError({
+ detail: "User not found",
+ status: 404
+ });
+
+ if (!membership.scimEnabled)
+ throw new ScimRequestError({
+ detail: "SCIM is disabled for the organization",
+ status: 403
+ });
+
+ let active = true;
+
+ operations.forEach((operation) => {
+ if (operation.op.toLowerCase() === "replace") {
+ if (operation.path === "active" && operation.value === "False") {
+ // azure scim op format
+ active = false;
+ } else if (typeof operation.value === "object" && operation.value.active === false) {
+ // okta scim op format
+ active = false;
+ }
+ }
+ });
+
+ if (!active) {
+ await deleteOrgMembership({
+ orgMembershipId: membership.id,
+ orgId: membership.orgId,
+ orgDAL
+ });
+ }
+
+ return buildScimUser({
+ userId: membership.userId as string,
+ firstName: membership.firstName as string,
+ lastName: membership.lastName as string,
+ email: membership.email,
+ active
+ });
+ };
+
+ const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => {
+ const [membership] = await orgDAL
+ .findMembership({
+ userId,
+ orgId
+ })
+ .catch(() => {
+ throw new ScimRequestError({
+ detail: "User not found",
+ status: 404
+ });
+ });
+
+ if (!membership)
+ throw new ScimRequestError({
+ detail: "User not found",
+ status: 404
+ });
+
+ if (!membership.scimEnabled)
+ throw new ScimRequestError({
+ detail: "SCIM is disabled for the organization",
+ status: 403
+ });
+
+ if (!active) {
+ // tx
+ await deleteOrgMembership({
+ orgMembershipId: membership.id,
+ orgId: membership.orgId,
+ orgDAL
+ });
+ }
+
+ return buildScimUser({
+ userId: membership.userId as string,
+ firstName: membership.firstName as string,
+ lastName: membership.lastName as string,
+ email: membership.email,
+ active
+ });
+ };
+
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
- // TODO: check expiry
-
const scimToken = await scimDAL.findById(token.scimTokenId);
if (!scimToken) throw new UnauthorizedError();
-
+
+ const { ttlDays, createdAt } = scimToken;
+
+ // ttl check
+ if (Number(ttlDays) > 0) {
+ const currentDate = new Date();
+ const scimTokenCreatedAt = new Date(createdAt);
+ const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400;
+ const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds);
+
+ if (currentDate > expirationDate)
+ throw new ScimRequestError({
+ detail: "The access token expired",
+ status: 401
+ });
+ }
+
return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
- }
-
- return {
+ };
+
+ return {
createScimToken,
- getScimTokens,
+ listScimTokens,
deleteScimToken,
- listUsers,
- getUser,
- createUser,
+ listScimUsers,
+ getScimUser,
+ createScimUser,
+ updateScimUser,
+ replaceScimUser,
fnValidateScimToken
};
};
diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts
index a10e9cc8eb..9751d591fa 100644
--- a/backend/src/ee/services/scim/scim-types.ts
+++ b/backend/src/ee/services/scim/scim-types.ts
@@ -1,41 +1,87 @@
import { TOrgPermission } from "@app/lib/types";
-import { TScimUser } from "@app/lib/scim";
export type TCreateScimTokenDTO = {
- organizationId: string;
- description: string;
- ttl: number;
-}
+ description: string;
+ ttlDays: number;
+} & TOrgPermission;
-// TODO: add org permissions
-// & Omit;
+export type TDeleteScimTokenDTO = {
+ scimTokenId: string;
+} & Omit;
+
+// SCIM server endpoint types
export type TListScimUsersDTO = {
- offset: number;
- limit: number;
- filter?: string;
-}
+ offset: number;
+ limit: number;
+ filter?: string;
+ orgId: string;
+};
-export type TListScimUsersRes = { // check naming here
- schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
- totalResults: number;
- Resources: TScimUser[];
- itemsPerPage: number;
- startIndex: number;
-}
+export type TListScimUsers = {
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
+ totalResults: number;
+ Resources: TScimUser[];
+ itemsPerPage: number;
+ startIndex: number;
+};
+
+export type TGetScimUserDTO = {
+ userId: string;
+ orgId: string;
+};
export type TCreateScimUserDTO = {
- email: string;
- firstName: string;
- lastName: string;
- orgId: string;
-}
+ email: string;
+ firstName: string;
+ lastName: string;
+ orgId: string;
+};
-export type TCreateScimUserRes = {
- schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"]
-}
+export type TUpdateScimUserDTO = {
+ userId: string;
+ orgId: string;
+ operations: {
+ op: string;
+ path?: string;
+ value?:
+ | string
+ | {
+ active: boolean;
+ };
+ }[];
+};
+
+export type TReplaceScimUserDTO = {
+ userId: string;
+ active: boolean;
+ orgId: string;
+};
export type TScimTokenJwtPayload = {
- scimTokenId: string;
- authTokenType: string;
-};
\ No newline at end of file
+ scimTokenId: string;
+ authTokenType: string;
+};
+
+export type TScimUser = {
+ schemas: string[];
+ id: string;
+ userName: string;
+ displayName: string;
+ name: {
+ givenName: string;
+ middleName: null;
+ familyName: string;
+ };
+ emails: {
+ primary: boolean;
+ value: string;
+ type: string;
+ }[];
+ active: boolean;
+ groups: string[];
+ meta: {
+ resourceType: string;
+ location: null;
+ };
+};
diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts
index a46d020669..d93244bbda 100644
--- a/backend/src/lib/errors/index.ts
+++ b/backend/src/lib/errors/index.ts
@@ -61,12 +61,27 @@ export class BadRequestError extends Error {
export class ScimRequestError extends Error {
name: string;
+
schemas: string[];
+
detail: string;
+
status: number;
+
error: unknown;
- constructor({ name, error, detail, status }: { message?: string; name?: string; error?: unknown, detail: string, status: number }) {
+ constructor({
+ name,
+ error,
+ detail,
+ status
+ }: {
+ message?: string;
+ name?: string;
+ error?: unknown;
+ detail: string;
+ status: number;
+ }) {
super(detail ?? "The request is invalid");
this.name = name || "ScimRequestError";
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
@@ -74,4 +89,4 @@ export class ScimRequestError extends Error {
this.detail = detail;
this.status = status;
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/lib/scim/fns.ts b/backend/src/lib/scim/fns.ts
deleted file mode 100644
index 430dd8fbbe..0000000000
--- a/backend/src/lib/scim/fns.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { TScimUser } from "./types";
-
-export const createScimUser = ({
- userId,
- firstName,
- lastName,
- email
-}: {
- userId: string;
- firstName: string;
- lastName: string;
- email: string;
-}): TScimUser => {
- let scimUser = {
- "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
- "id": userId,
- "userName": email,
- "displayName": `${firstName} ${lastName}`,
- "name": {
- "givenName": firstName,
- "middleName": null,
- "familyName": lastName
- },
- "emails":
- [{
- "primary": true,
- "value": email,
- "type": "work"
- }],
- "active": true,
- "groups": [],
- "meta": {
- "resourceType": "User",
- "location": null
- }
- };
-
- return scimUser;
-}
\ No newline at end of file
diff --git a/backend/src/lib/scim/index.ts b/backend/src/lib/scim/index.ts
deleted file mode 100644
index 8ab53e080d..0000000000
--- a/backend/src/lib/scim/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { TScimUser } from "./types";
-export {
- createScimUser
-} from "./fns";
\ No newline at end of file
diff --git a/backend/src/lib/scim/types.ts b/backend/src/lib/scim/types.ts
deleted file mode 100644
index f439c004e3..0000000000
--- a/backend/src/lib/scim/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-
-export type TScimUser = {
- schemas: string[];
- id: string;
- userName: string;
- displayName: string;
- name: {
- givenName: string;
- middleName: null;
- familyName: string;
- };
- emails: {
- primary: boolean;
- value: string;
- type: string;
- }[];
- active: boolean;
- groups: string[];
- meta: {
- resourceType: string;
- location: null;
- };
-}
\ No newline at end of file
diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts
index 7066764ff7..cf8d9dea33 100644
--- a/backend/src/server/plugins/auth/inject-identity.ts
+++ b/backend/src/server/plugins/auth/inject-identity.ts
@@ -3,11 +3,11 @@ import fp from "fastify-plugin";
import jwt, { JwtPayload } from "jsonwebtoken";
import { TServiceTokens, TUsers } from "@app/db/schemas";
+import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types";
-import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
export type TAuthMode =
| {
diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts
index ac19120308..c8da4077af 100644
--- a/backend/src/server/plugins/error-handler.ts
+++ b/backend/src/server/plugins/error-handler.ts
@@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability";
import fastifyPlugin from "fastify-plugin";
import { ZodError } from "zod";
-import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError, ScimRequestError } from "@app/lib/errors";
+import {
+ BadRequestError,
+ DatabaseError,
+ InternalServerError,
+ ScimRequestError,
+ UnauthorizedError
+} from "@app/lib/errors";
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
server.setErrorHandler((error, req, res) => {
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index b077992170..e557047481 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -11,6 +11,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
+import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
+import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal";
import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal";
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
@@ -32,8 +34,6 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
-import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
-import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
import { getConfig } from "@app/lib/config/env";
import { TQueueServiceFactory } from "@app/queue";
import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
@@ -191,13 +191,7 @@ export const registerRoutes = async (
trustedIpDAL,
permissionService
});
- const scimService = scimServiceFactory({
- licenseService,
- scimDAL,
- userDAL,
- orgDAL,
- permissionService
- });
+
const auditLogQueue = auditLogQueueServiceFactory({
auditLogDAL,
queueService,
@@ -220,6 +214,14 @@ export const registerRoutes = async (
samlConfigDAL,
licenseService
});
+ const scimService = scimServiceFactory({
+ licenseService,
+ scimDAL,
+ userDAL,
+ orgDAL,
+ permissionService,
+ smtpService
+ });
const telemetryService = telemetryServiceFactory();
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts
index bfda652f0c..6eff558fb0 100644
--- a/backend/src/server/routes/v1/organization-router.ts
+++ b/backend/src/server/routes/v1/organization-router.ts
@@ -93,7 +93,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
.trim()
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
.optional(),
- authEnforced: z.boolean().optional()
+ authEnforced: z.boolean().optional(),
+ scimEnabled: z.boolean().optional()
}),
response: {
200: z.object({
diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts
index e914aac421..bb3cbbd95a 100644
--- a/backend/src/services/org/org-dal.ts
+++ b/backend/src/services/org/org-dal.ts
@@ -165,7 +165,14 @@ export const orgDALFactory = (db: TDbClient) => {
// eslint-disable-next-line
.where(buildFindFilter(filter))
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
- .select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users));
+ .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
+ .select(
+ selectAllTableCols(TableName.OrgMembership),
+ db.ref("email").withSchema(TableName.Users),
+ db.ref("firstName").withSchema(TableName.Users),
+ db.ref("lastName").withSchema(TableName.Users),
+ db.ref("scimEnabled").withSchema(TableName.Organization)
+ );
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts
new file mode 100644
index 0000000000..9ad4d5ddb2
--- /dev/null
+++ b/backend/src/services/org/org-fns.ts
@@ -0,0 +1,21 @@
+import { TOrgDALFactory } from "@app/services/org/org-dal";
+
+type TDeleteOrgMembership = {
+ orgMembershipId: string;
+ orgId: string;
+ orgDAL: TOrgDALFactory;
+};
+
+export const deleteOrgMembership = async ({ orgMembershipId, orgId, orgDAL }: TDeleteOrgMembership) => {
+ // TODO: improve this implementation
+
+ // delete
+ const m2 = await orgDAL.transaction(async (tx) => {
+ const m1 = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
+ // const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
+ // delete project memberships
+ return m1;
+ });
+
+ return m2;
+};
diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts
index 7fafca4387..1d78ef0b9e 100644
--- a/backend/src/services/org/org-service.ts
+++ b/backend/src/services/org/org-service.ts
@@ -126,16 +126,32 @@ export const orgServiceFactory = ({
actorId,
actorOrgId,
orgId,
- data: { name, slug, authEnforced }
+ data: { name, slug, authEnforced, scimEnabled }
}: TUpdateOrgDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
+ const plan = await licenseService.getPlan(orgId);
+
if (authEnforced !== undefined) {
+ if (!plan?.samlSSO)
+ throw new BadRequestError({
+ message:
+ "Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO."
+ });
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
}
- if (authEnforced) {
+ if (scimEnabled !== undefined) {
+ if (!plan?.scim)
+ throw new BadRequestError({
+ message:
+ "Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
+ });
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
+ }
+
+ if (authEnforced || scimEnabled) {
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
if (!samlCfg)
throw new BadRequestError({
@@ -147,7 +163,8 @@ export const orgServiceFactory = ({
const org = await orgDAL.updateById(orgId, {
name,
slug: slug ? slugify(slug) : undefined,
- authEnforced
+ authEnforced,
+ scimEnabled
});
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
return org;
diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts
index 01b3c8e37c..bbb3252473 100644
--- a/backend/src/services/org/org-types.ts
+++ b/backend/src/services/org/org-types.ts
@@ -38,5 +38,5 @@ export type TFindAllWorkspacesDTO = {
};
export type TUpdateOrgDTO = {
- data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
+ data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
} & TOrgPermission;
diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts
index 142993ceb9..7ebeaa227a 100644
--- a/backend/src/services/smtp/smtp-service.ts
+++ b/backend/src/services/smtp/smtp-service.ts
@@ -25,7 +25,8 @@ export enum SmtpTemplates {
OrgInvite = "organizationInvitation.handlebars",
ResetPassword = "passwordReset.handlebars",
SecretLeakIncident = "secretLeakIncident.handlebars",
- WorkspaceInvite = "workspaceInvitation.handlebars"
+ WorkspaceInvite = "workspaceInvitation.handlebars",
+ ScimUserProvisioned = "scimUserProvisioned.handlebars"
}
export enum SmtpHost {
diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars
new file mode 100644
index 0000000000..b1482aa172
--- /dev/null
+++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Organization Invitation
+
+
+ Join your organization on Infisical
+ You've been invited to join the Infisical organization — {{organizationName}}
+ Join now
+ 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/docs/documentation/platform/scim/azure.mdx b/docs/documentation/platform/scim/azure.mdx
new file mode 100644
index 0000000000..45f95e1350
--- /dev/null
+++ b/docs/documentation/platform/scim/azure.mdx
@@ -0,0 +1,74 @@
+---
+title: "Azure SCIM"
+description: "Configure SCIM provisioning with Azure for Infisical"
+---
+
+
+ Azure SCIM provisioning is a paid feature.
+
+ If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
+ then you should contact team@infisical.com to purchase an enterprise license to use it.
+
+
+Prerequisites:
+- [Configure Azure SAML for Infisical](/documentation/platform/sso/azure)
+
+
+
+ In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
+ press the **Enable SCIM provisioning** toggle to allow Azure to provision/deprovision users for your organization.
+
+ 
+
+ Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Azure.
+
+ 
+
+ Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Azure.
+
+ 
+
+
+ In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**.
+
+ 
+
+ Next, set the following fields:
+
+ - Provisioning Mode: Select **Automatic**.
+ - Tenant URL: Input **SCIM URL** from Step 1.
+ - Secret Token: Input the **New SCIM Token** from Step 1.
+
+ Afterwards, press the **Test Connection** button to check that SCIM is configured properly.
+
+ 
+
+ After you hit **Save**, select **Provision Microsoft Entra ID Users** under the **Mappings** subsection.
+
+ 
+
+ Next, adjust the mappings so you have them configured as below:
+
+ 
+
+ Finally, head to your Enterprise Application > Provisioning and set the **Provisioning Status** to **On**.
+
+ 
+
+ Alternatively, you can go to **Overview** and press **Start provisioning** to have Azure start provisioning/deprovisioning users to Infisical.
+
+ 
+
+ Now Azure can provision/deprovision users to/from your organization in Infisical.
+
+
+
+**FAQ**
+
+
+
+ Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
+
+ For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
+
+
\ No newline at end of file
diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx
new file mode 100644
index 0000000000..68bb9b66f1
--- /dev/null
+++ b/docs/documentation/platform/scim/jumpcloud.mdx
@@ -0,0 +1,64 @@
+---
+title: "JumpCloud SCIM"
+description: "Configure SCIM provisioning with JumpCloud for Infisical"
+---
+
+
+ JumpCloud SCIM provisioning is a paid feature.
+
+ If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
+ then you should contact team@infisical.com to purchase an enterprise license to use it.
+
+
+Prerequisites:
+- [Configure JumpCloud SAML for Infisical](/documentation/platform/sso/jumpcloud)
+
+
+
+ In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
+ press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users for your organization.
+
+ 
+
+ Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for JumpCloud.
+
+ 
+
+ Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in JumpCloud.
+
+ 
+
+
+ In JumpCloud, head to your Application > Identity Management > Configuration settings and make sure that
+ **API Type** is set to **SCIM API** and **SCIM Version** is set to **SCIM 2.0**.
+
+ 
+
+ Next, set the following SCIM connection fields:
+
+ - Base URL: Input the **SCIM URL** from Step 1.
+ - Token Key: Input the **New SCIM Token** from Step 1.
+ - Test User Email: Input a test user email to be used by JumpCloud for testing the SCIM connection.
+
+ Alos, under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1.
+
+ 
+
+ Next, press **Test Connection** to check that SCIM is configured properly. Finally, press **Activate**
+ to have JumpCloud start provisioning/deprovisioning users to Infisical.
+
+ 
+
+ Now JumpCloud can provision/deprovision users to/from your organization in Infisical.
+
+
+
+**FAQ**
+
+
+
+ Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
+
+ For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
+
+
\ No newline at end of file
diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx
new file mode 100644
index 0000000000..4baa198157
--- /dev/null
+++ b/docs/documentation/platform/scim/okta.mdx
@@ -0,0 +1,70 @@
+---
+title: "Okta SCIM"
+description: "Configure SCIM provisioning with Okta for Infisical"
+---
+
+
+ Okta SCIM provisioning is a paid feature.
+
+ If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
+ then you should contact team@infisical.com to purchase an enterprise license to use it.
+
+
+Prerequisites:
+- [Configure Okta SAML for Infisical](/documentation/platform/sso/okta)
+
+
+
+ In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and
+ press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users for your organization.
+
+ 
+
+ Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Okta.
+
+ 
+
+ Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Okta.
+
+ 
+
+
+ In Okta, head to your Application > General > App Settings. Next, select **Edit** and check the box
+ labled **Enable SCIM provisioning**.
+
+ 
+
+ Next, head to Provisioning > Integration and set the following SCIM connection fields:
+
+ - SCIM connector base URL: Input the **SCIM URL** from Step 1.
+ - Unique identifier field for users: Input `email`.
+ - Supported provisioning actions: Select **Push New Users** and **Push Profile Updates**.
+ - Authentication Mode: `HTTP Header`.
+
+ 
+
+ Under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1.
+
+ 
+
+ Next, press **Test Connector Configuration** to check that SCIM is configured properly.
+
+ 
+
+ Next, head to Provisioning > To App and check the boxes labeled **Enable** for **Create Users**, **Update User Attributes**, and **Deactivate Users**.
+
+ 
+
+ Now Okta can provision/deprovision users to/from your organization in Infisical.
+
+
+
+**FAQ**
+
+
+
+ Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
+
+ For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
+
+
\ No newline at end of file
diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx
new file mode 100644
index 0000000000..deec8b6304
--- /dev/null
+++ b/docs/documentation/platform/scim/overview.mdx
@@ -0,0 +1,32 @@
+---
+title: "SCIM Overview"
+description: "Provision users for Infisical via SCIM"
+---
+
+
+ SCIM provisioning is a paid feature.
+
+ If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
+ then you should contact team@infisical.com to purchase an enterprise license to use it.
+
+
+You can configure your organization in Infisical to have members be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc.
+
+- Provisioning: The SCIM provider pushes user information to Infisical. If the user exists in Infisical, Infisical sends an email invitation to add them to the relevant organization in Infisical; if not, Infisical initializes a new user and sends them an email invitation to finish setting up their account in the organization.
+- Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical.
+
+SCIM providers:
+
+- [Okta SCIM](/documentation/platform/scim/okta)
+- [Azure SCIM](/documentation/platform/scim/azure)
+- [JumpCloud SCIM](/documentation/platform/scim/jumpcloud)
+
+**FAQ**
+
+
+
+ Infisical's SCIM implementation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
+
+ For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
+
+
\ No newline at end of file
diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx
index 0f644834ab..35f287cdfa 100644
--- a/docs/documentation/platform/sso/azure.mdx
+++ b/docs/documentation/platform/sso/azure.mdx
@@ -12,7 +12,7 @@ description: "Configure Azure SAML for Infisical SSO"
- In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
+ In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application.
diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx
index 2273a8852a..0366bbf7f0 100644
--- a/docs/documentation/platform/sso/jumpcloud.mdx
+++ b/docs/documentation/platform/sso/jumpcloud.mdx
@@ -12,7 +12,7 @@ description: "Configure JumpCloud SAML for Infisical SSO"
- In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
+ In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application.
diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx
index 576bd769a0..354cc18000 100644
--- a/docs/documentation/platform/sso/okta.mdx
+++ b/docs/documentation/platform/sso/okta.mdx
@@ -12,7 +12,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO"
- In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
+ In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application.

diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx
index 8f4b3bb0f2..cd2f8ff317 100644
--- a/docs/documentation/platform/sso/overview.mdx
+++ b/docs/documentation/platform/sso/overview.mdx
@@ -3,13 +3,13 @@ title: "SSO Overview"
description: "Log in to Infisical via SSO protocols"
---
-
+
Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted.
Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier
or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including
- Okta, Azure AD, and JumpCloud; with any questions, please reach out to [sales@infisical.com](mailto:sales@infisical.com).
-
+ Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com.
+
You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0).
diff --git a/docs/images/platform/scim/azure/scim-azure-config.png b/docs/images/platform/scim/azure/scim-azure-config.png
new file mode 100644
index 0000000000000000000000000000000000000000..5255c3a8d14fada93a55093ae690df525953fc2c
GIT binary patch
literal 233456
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