@@ -6,6 +6,7 @@ import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
|||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||||
|
import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||||
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
|
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
|
||||||
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
||||||
@@ -105,6 +106,7 @@ declare module "fastify" {
|
|||||||
secretRotation: TSecretRotationServiceFactory;
|
secretRotation: TSecretRotationServiceFactory;
|
||||||
snapshot: TSecretSnapshotServiceFactory;
|
snapshot: TSecretSnapshotServiceFactory;
|
||||||
saml: TSamlConfigServiceFactory;
|
saml: TSamlConfigServiceFactory;
|
||||||
|
scim: TScimServiceFactory;
|
||||||
auditLog: TAuditLogServiceFactory;
|
auditLog: TAuditLogServiceFactory;
|
||||||
secretScanning: TSecretScanningServiceFactory;
|
secretScanning: TSecretScanningServiceFactory;
|
||||||
license: TLicenseServiceFactory;
|
license: TLicenseServiceFactory;
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ import {
|
|||||||
TSamlConfigs,
|
TSamlConfigs,
|
||||||
TSamlConfigsInsert,
|
TSamlConfigsInsert,
|
||||||
TSamlConfigsUpdate,
|
TSamlConfigsUpdate,
|
||||||
|
TScimTokens,
|
||||||
|
TScimTokensInsert,
|
||||||
|
TScimTokensUpdate,
|
||||||
TSecretApprovalPolicies,
|
TSecretApprovalPolicies,
|
||||||
TSecretApprovalPoliciesApprovers,
|
TSecretApprovalPoliciesApprovers,
|
||||||
TSecretApprovalPoliciesApproversInsert,
|
TSecretApprovalPoliciesApproversInsert,
|
||||||
@@ -262,6 +265,7 @@ declare module "knex/types/tables" {
|
|||||||
TIdentityProjectMembershipsInsert,
|
TIdentityProjectMembershipsInsert,
|
||||||
TIdentityProjectMembershipsUpdate
|
TIdentityProjectMembershipsUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||||
TSecretApprovalPolicies,
|
TSecretApprovalPolicies,
|
||||||
TSecretApprovalPoliciesInsert,
|
TSecretApprovalPoliciesInsert,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.ScimToken))) {
|
||||||
|
await knex.schema.createTable(TableName.ScimToken, (t) => {
|
||||||
|
t.string("id", 36).primary().defaultTo(knex.fn.uuid());
|
||||||
|
t.bigInteger("ttlDays").defaultTo(365).notNullable();
|
||||||
|
t.string("description").notNullable();
|
||||||
|
t.uuid("orgId").notNullable();
|
||||||
|
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
t.boolean("scimEnabled").defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await createOnUpdateTrigger(knex, TableName.ScimToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.ScimToken);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.ScimToken);
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
t.dropColumn("scimEnabled");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export * from "./project-memberships";
|
|||||||
export * from "./project-roles";
|
export * from "./project-roles";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
export * from "./saml-configs";
|
export * from "./saml-configs";
|
||||||
|
export * from "./scim-tokens";
|
||||||
export * from "./secret-approval-policies";
|
export * from "./secret-approval-policies";
|
||||||
export * from "./secret-approval-policies-approvers";
|
export * from "./secret-approval-policies-approvers";
|
||||||
export * from "./secret-approval-request-secret-tags";
|
export * from "./secret-approval-request-secret-tags";
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export enum TableName {
|
|||||||
IdentityUaClientSecret = "identity_ua_client_secrets",
|
IdentityUaClientSecret = "identity_ua_client_secrets",
|
||||||
IdentityOrgMembership = "identity_org_memberships",
|
IdentityOrgMembership = "identity_org_memberships",
|
||||||
IdentityProjectMembership = "identity_project_memberships",
|
IdentityProjectMembership = "identity_project_memberships",
|
||||||
|
ScimToken = "scim_tokens",
|
||||||
SecretApprovalPolicy = "secret_approval_policies",
|
SecretApprovalPolicy = "secret_approval_policies",
|
||||||
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
||||||
SecretApprovalRequest = "secret_approval_requests",
|
SecretApprovalRequest = "secret_approval_requests",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export const OrganizationsSchema = z.object({
|
|||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: 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<typeof OrganizationsSchema>;
|
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const ScimTokensSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
ttlDays: z.coerce.number().default(365),
|
||||||
|
description: z.string(),
|
||||||
|
orgId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TScimTokens = z.infer<typeof ScimTokensSchema>;
|
||||||
|
export type TScimTokensInsert = Omit<TScimTokens, TImmutableDBKeys>;
|
||||||
|
export type TScimTokensUpdate = Partial<Omit<TScimTokens, TImmutableDBKeys>>;
|
||||||
@@ -3,6 +3,7 @@ import { registerOrgRoleRouter } from "./org-role-router";
|
|||||||
import { registerProjectRoleRouter } from "./project-role-router";
|
import { registerProjectRoleRouter } from "./project-role-router";
|
||||||
import { registerProjectRouter } from "./project-router";
|
import { registerProjectRouter } from "./project-router";
|
||||||
import { registerSamlRouter } from "./saml-router";
|
import { registerSamlRouter } from "./saml-router";
|
||||||
|
import { registerScimRouter } from "./scim-router";
|
||||||
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
|
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
|
||||||
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
|
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
|
||||||
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
|
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
|
||||||
@@ -33,6 +34,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
|||||||
prefix: "/secret-rotation-providers"
|
prefix: "/secret-rotation-providers"
|
||||||
});
|
});
|
||||||
await server.register(registerSamlRouter, { prefix: "/sso" });
|
await server.register(registerSamlRouter, { prefix: "/sso" });
|
||||||
|
await server.register(registerScimRouter, { prefix: "/scim" });
|
||||||
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
|
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
|
||||||
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
|
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
|
||||||
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
|
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ScimTokensSchema } from "@app/db/schemas";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
|
||||||
|
try {
|
||||||
|
const strBody = body instanceof Buffer ? body.toString() : body;
|
||||||
|
|
||||||
|
const json: unknown = JSON.parse(strBody);
|
||||||
|
done(null, json);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
done(error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens",
|
||||||
|
method: "POST",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
organizationId: z.string().trim(),
|
||||||
|
description: z.string().trim().default(""),
|
||||||
|
ttlDays: z.number().min(0).default(0)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimToken: z.string().trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const { scimToken } = await server.services.scim.createScimToken({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
orgId: req.body.organizationId,
|
||||||
|
description: req.body.description,
|
||||||
|
ttlDays: req.body.ttlDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scimToken };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens",
|
||||||
|
method: "GET",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
organizationId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimTokens: z.array(ScimTokensSchema)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens/:scimTokenId",
|
||||||
|
method: "DELETE",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
scimTokenId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimToken: ScimTokensSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
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()
|
||||||
|
}),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
|
|||||||
|
|
||||||
export type TCreateAuditLogDTO = {
|
export type TCreateAuditLogDTO = {
|
||||||
event: Event;
|
event: Event;
|
||||||
actor: UserActor | IdentityActor | ServiceActor;
|
actor: UserActor | IdentityActor | ServiceActor | ScimClientActor;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
} & BaseAuthData;
|
} & BaseAuthData;
|
||||||
@@ -105,6 +105,8 @@ interface IdentityActorMetadata {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScimClientActorMetadata {}
|
||||||
|
|
||||||
export interface UserActor {
|
export interface UserActor {
|
||||||
type: ActorType.USER;
|
type: ActorType.USER;
|
||||||
metadata: UserActorMetadata;
|
metadata: UserActorMetadata;
|
||||||
@@ -120,7 +122,12 @@ export interface IdentityActor {
|
|||||||
metadata: IdentityActorMetadata;
|
metadata: IdentityActorMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Actor = UserActor | ServiceActor | IdentityActor;
|
export interface ScimClientActor {
|
||||||
|
type: ActorType.SCIM_CLIENT;
|
||||||
|
metadata: ScimClientActorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor;
|
||||||
|
|
||||||
interface GetSecretsEvent {
|
interface GetSecretsEvent {
|
||||||
type: EventType.GET_SECRETS;
|
type: EventType.GET_SECRETS;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
auditLogs: false,
|
auditLogs: false,
|
||||||
auditLogsRetentionDays: 0,
|
auditLogsRetentionDays: 0,
|
||||||
samlSSO: false,
|
samlSSO: false,
|
||||||
|
scim: false,
|
||||||
status: null,
|
status: null,
|
||||||
trial_end: null,
|
trial_end: null,
|
||||||
has_used_trial: true,
|
has_used_trial: true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type TFeatureSet = {
|
|||||||
auditLogs: false;
|
auditLogs: false;
|
||||||
auditLogsRetentionDays: 0;
|
auditLogsRetentionDays: 0;
|
||||||
samlSSO: false;
|
samlSSO: false;
|
||||||
|
scim: false;
|
||||||
status: null;
|
status: null;
|
||||||
trial_end: null;
|
trial_end: null;
|
||||||
has_used_trial: true;
|
has_used_trial: true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
|
|||||||
Settings = "settings",
|
Settings = "settings",
|
||||||
IncidentAccount = "incident-contact",
|
IncidentAccount = "incident-contact",
|
||||||
Sso = "sso",
|
Sso = "sso",
|
||||||
|
Scim = "scim",
|
||||||
Billing = "billing",
|
Billing = "billing",
|
||||||
SecretScanning = "secret-scanning",
|
SecretScanning = "secret-scanning",
|
||||||
Identity = "identity"
|
Identity = "identity"
|
||||||
@@ -29,6 +30,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
|
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
|
||||||
@@ -69,6 +71,11 @@ const buildAdminPermission = () => {
|
|||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||||
can(OrgPermissionActions.Delete, 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.Read, OrgPermissionSubjects.Billing);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({
|
|||||||
updateQuery.certTag = certTag;
|
updateQuery.certTag = certTag;
|
||||||
}
|
}
|
||||||
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
|
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
|
||||||
await orgDAL.updateById(orgId, { authEnforced: false });
|
await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false });
|
||||||
|
|
||||||
return ssoConfig;
|
return ssoConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TScimDALFactory = ReturnType<typeof scimDALFactory>;
|
||||||
|
|
||||||
|
export const scimDALFactory = (db: TDbClient) => {
|
||||||
|
const scimTokenOrm = ormify(db, TableName.ScimToken);
|
||||||
|
return scimTokenOrm;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
||||||
|
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||||
|
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 { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
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,
|
||||||
|
TCreateScimUserDTO,
|
||||||
|
TDeleteScimTokenDTO,
|
||||||
|
TGetScimUserDTO,
|
||||||
|
TListScimUsers,
|
||||||
|
TListScimUsersDTO,
|
||||||
|
TReplaceScimUserDTO,
|
||||||
|
TScimTokenJwtPayload,
|
||||||
|
TUpdateScimUserDTO
|
||||||
|
} from "./scim-types";
|
||||||
|
|
||||||
|
type TScimServiceFactoryDep = {
|
||||||
|
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
|
||||||
|
userDAL: Pick<TUserDALFactory, "findOne" | "create" | "transaction">;
|
||||||
|
orgDAL: Pick<
|
||||||
|
TOrgDALFactory,
|
||||||
|
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction"
|
||||||
|
>;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
|
smtpService: TSmtpService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
|
||||||
|
|
||||||
|
export const scimServiceFactory = ({
|
||||||
|
licenseService,
|
||||||
|
scimDAL,
|
||||||
|
userDAL,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
permissionService,
|
||||||
|
smtpService
|
||||||
|
}: TScimServiceFactoryDep) => {
|
||||||
|
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();
|
||||||
|
|
||||||
|
const scimTokenData = await scimDAL.create({
|
||||||
|
orgId,
|
||||||
|
description,
|
||||||
|
ttlDays
|
||||||
|
});
|
||||||
|
|
||||||
|
const scimToken = jwt.sign(
|
||||||
|
{
|
||||||
|
scimTokenId: scimTokenData.id,
|
||||||
|
authTokenType: AuthTokenType.SCIM_TOKEN
|
||||||
|
},
|
||||||
|
appCfg.AUTH_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
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, 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<TListScimUsers> => {
|
||||||
|
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());
|
||||||
|
|
||||||
|
let attributeName = parsedName;
|
||||||
|
if (parsedName === "userName") {
|
||||||
|
attributeName = "email";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [attributeName]: parsedValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOpts = {
|
||||||
|
...(offset && { offset }),
|
||||||
|
...(limit && { limit })
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: membership.userId as string,
|
||||||
|
firstName: membership.firstName as string,
|
||||||
|
lastName: membership.lastName as string,
|
||||||
|
email: membership.email,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: user.id,
|
||||||
|
firstName: user.firstName as string,
|
||||||
|
lastName: user.lastName as string,
|
||||||
|
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,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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 * 1000;
|
||||||
|
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 {
|
||||||
|
createScimToken,
|
||||||
|
listScimTokens,
|
||||||
|
deleteScimToken,
|
||||||
|
listScimUsers,
|
||||||
|
getScimUser,
|
||||||
|
createScimUser,
|
||||||
|
updateScimUser,
|
||||||
|
replaceScimUser,
|
||||||
|
fnValidateScimToken
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { TOrgPermission } from "@app/lib/types";
|
||||||
|
|
||||||
|
export type TCreateScimTokenDTO = {
|
||||||
|
description: string;
|
||||||
|
ttlDays: number;
|
||||||
|
} & TOrgPermission;
|
||||||
|
|
||||||
|
export type TDeleteScimTokenDTO = {
|
||||||
|
scimTokenId: string;
|
||||||
|
} & Omit<TOrgPermission, "orgId">;
|
||||||
|
|
||||||
|
// SCIM server endpoint types
|
||||||
|
|
||||||
|
export type TListScimUsersDTO = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
filter?: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -58,3 +58,35 @@ export class BadRequestError extends Error {
|
|||||||
this.error = error;
|
this.error = 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;
|
||||||
|
}) {
|
||||||
|
super(detail ?? "The request is invalid");
|
||||||
|
this.name = name || "ScimRequestError";
|
||||||
|
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
|
||||||
|
this.error = error;
|
||||||
|
this.detail = detail;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => {
|
|||||||
identityId: req.auth.identityId
|
identityId: req.auth.identityId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
} else if (req.auth.actor === ActorType.SCIM_CLIENT) {
|
||||||
|
payload.actor = {
|
||||||
|
type: ActorType.SCIM_CLIENT,
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({ message: "Missing logic for other actor" });
|
throw new BadRequestError({ message: "Missing logic for other actor" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fp from "fastify-plugin";
|
|||||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||||
|
|
||||||
import { TServiceTokens, TUsers } from "@app/db/schemas";
|
import { TServiceTokens, TUsers } from "@app/db/schemas";
|
||||||
|
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { UnauthorizedError } from "@app/lib/errors";
|
import { UnauthorizedError } from "@app/lib/errors";
|
||||||
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
||||||
@@ -35,6 +36,12 @@ export type TAuthMode =
|
|||||||
actor: ActorType.IDENTITY;
|
actor: ActorType.IDENTITY;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
identityName: string;
|
identityName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
authMode: AuthMode.SCIM_TOKEN;
|
||||||
|
actor: ActorType.SCIM_CLIENT;
|
||||||
|
scimTokenId: string;
|
||||||
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
||||||
@@ -55,6 +62,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload;
|
const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
switch (decodedToken.authTokenType) {
|
switch (decodedToken.authTokenType) {
|
||||||
case AuthTokenType.ACCESS_TOKEN:
|
case AuthTokenType.ACCESS_TOKEN:
|
||||||
return {
|
return {
|
||||||
@@ -70,6 +78,12 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
|||||||
token: decodedToken as TIdentityAccessTokenJwtPayload,
|
token: decodedToken as TIdentityAccessTokenJwtPayload,
|
||||||
actor: ActorType.IDENTITY
|
actor: ActorType.IDENTITY
|
||||||
} as const;
|
} as const;
|
||||||
|
case AuthTokenType.SCIM_TOKEN:
|
||||||
|
return {
|
||||||
|
authMode: AuthMode.SCIM_TOKEN,
|
||||||
|
token: decodedToken as TScimTokenJwtPayload,
|
||||||
|
actor: ActorType.SCIM_CLIENT
|
||||||
|
} as const;
|
||||||
default:
|
default:
|
||||||
return { authMode: null, token: null } as const;
|
return { authMode: null, token: null } as const;
|
||||||
}
|
}
|
||||||
@@ -113,6 +127,11 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
|
|||||||
req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user };
|
req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case AuthMode.SCIM_TOKEN: {
|
||||||
|
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
|
||||||
|
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId };
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new UnauthorizedError({ name: "Unknown token strategy" });
|
throw new UnauthorizedError({ name: "Unknown token strategy" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const injectPermission = fp(async (server) => {
|
|||||||
req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId };
|
req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId };
|
||||||
} else if (req.auth.actor === ActorType.SERVICE) {
|
} else if (req.auth.actor === ActorType.SERVICE) {
|
||||||
req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId };
|
req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId };
|
||||||
|
} else if (req.auth.actor === ActorType.SCIM_CLIENT) {
|
||||||
|
req.permission = { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability";
|
|||||||
import fastifyPlugin from "fastify-plugin";
|
import fastifyPlugin from "fastify-plugin";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError } from "@app/lib/errors";
|
import {
|
||||||
|
BadRequestError,
|
||||||
|
DatabaseError,
|
||||||
|
InternalServerError,
|
||||||
|
ScimRequestError,
|
||||||
|
UnauthorizedError
|
||||||
|
} from "@app/lib/errors";
|
||||||
|
|
||||||
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
|
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
|
||||||
server.setErrorHandler((error, req, res) => {
|
server.setErrorHandler((error, req, res) => {
|
||||||
@@ -21,6 +27,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
|||||||
error: "PermissionDenied",
|
error: "PermissionDenied",
|
||||||
message: `You are not allowed to ${error.action} on ${error.subjectType}`
|
message: `You are not allowed to ${error.action} on ${error.subjectType}`
|
||||||
});
|
});
|
||||||
|
} else if (error instanceof ScimRequestError) {
|
||||||
|
void res.status(error.status).send({
|
||||||
|
schemas: error.schemas,
|
||||||
|
status: error.status,
|
||||||
|
detail: error.detail
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
void res.send(error);
|
void res.send(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal
|
|||||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
||||||
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
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 { 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 { 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";
|
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||||
@@ -155,6 +157,7 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
const auditLogDAL = auditLogDALFactory(db);
|
const auditLogDAL = auditLogDALFactory(db);
|
||||||
const trustedIpDAL = trustedIpDALFactory(db);
|
const trustedIpDAL = trustedIpDALFactory(db);
|
||||||
|
const scimDAL = scimDALFactory(db);
|
||||||
|
|
||||||
// ee db layer ops
|
// ee db layer ops
|
||||||
const permissionDAL = permissionDALFactory(db);
|
const permissionDAL = permissionDALFactory(db);
|
||||||
@@ -188,6 +191,7 @@ export const registerRoutes = async (
|
|||||||
trustedIpDAL,
|
trustedIpDAL,
|
||||||
permissionService
|
permissionService
|
||||||
});
|
});
|
||||||
|
|
||||||
const auditLogQueue = auditLogQueueServiceFactory({
|
const auditLogQueue = auditLogQueueServiceFactory({
|
||||||
auditLogDAL,
|
auditLogDAL,
|
||||||
queueService,
|
queueService,
|
||||||
@@ -210,6 +214,16 @@ export const registerRoutes = async (
|
|||||||
samlConfigDAL,
|
samlConfigDAL,
|
||||||
licenseService
|
licenseService
|
||||||
});
|
});
|
||||||
|
const scimService = scimServiceFactory({
|
||||||
|
licenseService,
|
||||||
|
scimDAL,
|
||||||
|
userDAL,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
permissionService,
|
||||||
|
smtpService
|
||||||
|
});
|
||||||
|
|
||||||
const telemetryService = telemetryServiceFactory();
|
const telemetryService = telemetryServiceFactory();
|
||||||
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
||||||
@@ -486,6 +500,7 @@ export const registerRoutes = async (
|
|||||||
secretScanning: secretScanningService,
|
secretScanning: secretScanningService,
|
||||||
license: licenseService,
|
license: licenseService,
|
||||||
trustedIp: trustedIpService,
|
trustedIp: trustedIpService,
|
||||||
|
scim: scimService,
|
||||||
secretBlindIndex: secretBlindIndexService,
|
secretBlindIndex: secretBlindIndexService,
|
||||||
telemetry: telemetryService
|
telemetry: telemetryService
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
.trim()
|
.trim()
|
||||||
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||||
.optional(),
|
.optional(),
|
||||||
authEnforced: z.boolean().optional()
|
authEnforced: z.boolean().optional(),
|
||||||
|
scimEnabled: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -17,21 +17,24 @@ export enum AuthTokenType {
|
|||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
|
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
|
||||||
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
|
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
|
||||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||||
|
SCIM_TOKEN = "scimToken"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthMode {
|
export enum AuthMode {
|
||||||
JWT = "jwt",
|
JWT = "jwt",
|
||||||
SERVICE_TOKEN = "serviceToken",
|
SERVICE_TOKEN = "serviceToken",
|
||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||||
|
SCIM_TOKEN = "scimToken"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ActorType { // would extend to AWS, Azure, ...
|
export enum ActorType { // would extend to AWS, Azure, ...
|
||||||
USER = "user", // userIdentity
|
USER = "user", // userIdentity
|
||||||
SERVICE = "service",
|
SERVICE = "service",
|
||||||
IDENTITY = "identity",
|
IDENTITY = "identity",
|
||||||
Machine = "machine"
|
Machine = "machine",
|
||||||
|
SCIM_CLIENT = "scimClient"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthModeJwtTokenPayload = {
|
export type AuthModeJwtTokenPayload = {
|
||||||
|
|||||||
@@ -165,7 +165,14 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
.where(buildFindFilter(filter))
|
.where(buildFindFilter(filter))
|
||||||
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
.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 (limit) void query.limit(limit);
|
||||||
if (offset) void query.offset(offset);
|
if (offset) void query.offset(offset);
|
||||||
if (sort) {
|
if (sort) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
|
||||||
|
type TDeleteOrgMembership = {
|
||||||
|
orgMembershipId: string;
|
||||||
|
orgId: string;
|
||||||
|
orgDAL: Pick<TOrgDALFactory, "findMembership" | "deleteMembershipById" | "transaction">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteOrgMembership = async ({
|
||||||
|
orgMembershipId,
|
||||||
|
orgId,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
}: TDeleteOrgMembership) => {
|
||||||
|
const membership = await orgDAL.transaction(async (tx) => {
|
||||||
|
// delete org membership
|
||||||
|
const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
|
||||||
|
|
||||||
|
const projects = await projectDAL.find({ orgId }, { tx });
|
||||||
|
|
||||||
|
// delete associated project memberships
|
||||||
|
await projectMembershipDAL.delete(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
projectId: projects.map((project) => project.id)
|
||||||
|
},
|
||||||
|
userId: orgMembership.userId as string
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
return orgMembership;
|
||||||
|
});
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
};
|
||||||
@@ -126,16 +126,32 @@ export const orgServiceFactory = ({
|
|||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
orgId,
|
orgId,
|
||||||
data: { name, slug, authEnforced }
|
data: { name, slug, authEnforced, scimEnabled }
|
||||||
}: TUpdateOrgDTO) => {
|
}: TUpdateOrgDTO) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgId);
|
||||||
|
|
||||||
if (authEnforced !== undefined) {
|
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);
|
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);
|
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
|
||||||
if (!samlCfg)
|
if (!samlCfg)
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
@@ -147,7 +163,8 @@ export const orgServiceFactory = ({
|
|||||||
const org = await orgDAL.updateById(orgId, {
|
const org = await orgDAL.updateById(orgId, {
|
||||||
name,
|
name,
|
||||||
slug: slug ? slugify(slug) : undefined,
|
slug: slug ? slugify(slug) : undefined,
|
||||||
authEnforced
|
authEnforced,
|
||||||
|
scimEnabled
|
||||||
});
|
});
|
||||||
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
||||||
return org;
|
return org;
|
||||||
|
|||||||
@@ -38,5 +38,5 @@ export type TFindAllWorkspacesDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TUpdateOrgDTO = {
|
export type TUpdateOrgDTO = {
|
||||||
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
|
data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
|
||||||
} & TOrgPermission;
|
} & TOrgPermission;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export enum SmtpTemplates {
|
|||||||
OrgInvite = "organizationInvitation.handlebars",
|
OrgInvite = "organizationInvitation.handlebars",
|
||||||
ResetPassword = "passwordReset.handlebars",
|
ResetPassword = "passwordReset.handlebars",
|
||||||
SecretLeakIncident = "secretLeakIncident.handlebars",
|
SecretLeakIncident = "secretLeakIncident.handlebars",
|
||||||
WorkspaceInvite = "workspaceInvitation.handlebars"
|
WorkspaceInvite = "workspaceInvitation.handlebars",
|
||||||
|
ScimUserProvisioned = "scimUserProvisioned.handlebars"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SmtpHost {
|
export enum SmtpHost {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<title>Organization Invitation</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Join your organization on Infisical</h2>
|
||||||
|
<p>You've been invited to join the Infisical organization — {{organizationName}}</p>
|
||||||
|
<a href="{{callback_url}}">Join now</a>
|
||||||
|
<h3>What is Infisical?</h3>
|
||||||
|
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: "Azure SCIM"
|
||||||
|
description: "Configure SCIM provisioning with Azure for Infisical"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
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 [email protected] to purchase an enterprise license to use it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- [Configure Azure SAML for Infisical](/documentation/platform/sso/azure)
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Create a SCIM token in Infisical">
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Configure 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.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
**FAQ**
|
||||||
|
|
||||||
|
<AccordionGroup>
|
||||||
|
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||||
|
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.
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
title: "JumpCloud SCIM"
|
||||||
|
description: "Configure SCIM provisioning with JumpCloud for Infisical"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
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 [email protected] to purchase an enterprise license to use it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- [Configure JumpCloud SAML for Infisical](/documentation/platform/sso/jumpcloud)
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Create a SCIM token in Infisical">
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Configure 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.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
**FAQ**
|
||||||
|
|
||||||
|
<AccordionGroup>
|
||||||
|
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||||
|
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.
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
title: "Okta SCIM"
|
||||||
|
description: "Configure SCIM provisioning with Okta for Infisical"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
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 [email protected] to purchase an enterprise license to use it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- [Configure Okta SAML for Infisical](/documentation/platform/sso/okta)
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Create a SCIM token in Infisical">
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Configure 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.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
**FAQ**
|
||||||
|
|
||||||
|
<AccordionGroup>
|
||||||
|
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||||
|
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.
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
title: "SCIM Overview"
|
||||||
|
description: "Provision users for Infisical via SCIM"
|
||||||
|
---
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
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 [email protected] to purchase an enterprise license to use it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
|
<AccordionGroup>
|
||||||
|
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
|
||||||
|
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.
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
@@ -12,7 +12,7 @@ description: "Configure Azure SAML for Infisical SSO"
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||||
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.
|
Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application.
|
||||||
|
|
||||||
@@ -101,6 +101,11 @@ description: "Configure Azure SAML for Infisical SSO"
|
|||||||
|
|
||||||
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Azure user with Infisical;
|
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Azure user with Infisical;
|
||||||
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
We recommend ensuring that your account is provisioned the application in Azure
|
||||||
|
prior to enforcing SAML SSO to prevent any unintended issues.
|
||||||
|
</Warning>
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ description: "Configure JumpCloud SAML for Infisical SSO"
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||||
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.
|
Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application.
|
||||||
|
|
||||||
@@ -81,6 +81,11 @@ description: "Configure JumpCloud SAML for Infisical SSO"
|
|||||||
|
|
||||||
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical;
|
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical;
|
||||||
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
We recommend ensuring that your account is provisioned the application in JumpCloud
|
||||||
|
prior to enforcing SAML SSO to prevent any unintended issues.
|
||||||
|
</Warning>
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO"
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||||
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.
|
Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application.
|
||||||

|

|
||||||
@@ -84,6 +84,11 @@ description: "Configure Okta SAML 2.0 for Infisical SSO"
|
|||||||
|
|
||||||
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Okta user with Infisical;
|
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Okta user with Infisical;
|
||||||
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
We recommend ensuring that your account is provisioned the application in Okta
|
||||||
|
prior to enforcing SAML SSO to prevent any unintended issues.
|
||||||
|
</Warning>
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ title: "SSO Overview"
|
|||||||
description: "Log in to Infisical via SSO protocols"
|
description: "Log in to Infisical via SSO protocols"
|
||||||
---
|
---
|
||||||
|
|
||||||
<Warning>
|
<Info>
|
||||||
Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted.
|
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
|
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
|
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 [[email protected]](mailto:sales@infisical.com).
|
Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com.
|
||||||
</Warning>
|
</Info>
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 513 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 618 KiB |
@@ -148,6 +148,15 @@
|
|||||||
"documentation/platform/sso/azure",
|
"documentation/platform/sso/azure",
|
||||||
"documentation/platform/sso/jumpcloud"
|
"documentation/platform/sso/jumpcloud"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "SCIM",
|
||||||
|
"pages": [
|
||||||
|
"documentation/platform/scim/overview",
|
||||||
|
"documentation/platform/scim/okta",
|
||||||
|
"documentation/platform/scim/azure",
|
||||||
|
"documentation/platform/scim/jumpcloud"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export enum OrgPermissionSubjects {
|
|||||||
Member = "member",
|
Member = "member",
|
||||||
Settings = "settings",
|
Settings = "settings",
|
||||||
IncidentAccount = "incident-contact",
|
IncidentAccount = "incident-contact",
|
||||||
|
Scim = "scim",
|
||||||
Sso = "sso",
|
Sso = "sso",
|
||||||
Billing = "billing",
|
Billing = "billing",
|
||||||
SecretScanning = "secret-scanning",
|
SecretScanning = "secret-scanning",
|
||||||
@@ -26,6 +27,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Member]
|
| [OrgPermissionActions, OrgPermissionSubjects.Member]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from "./integrations";
|
|||||||
export * from "./keys";
|
export * from "./keys";
|
||||||
export * from "./organization";
|
export * from "./organization";
|
||||||
export * from "./roles";
|
export * from "./roles";
|
||||||
|
export * from "./scim";
|
||||||
export * from "./secretApproval";
|
export * from "./secretApproval";
|
||||||
export * from "./secretApprovalRequest";
|
export * from "./secretApprovalRequest";
|
||||||
export * from "./secretFolders";
|
export * from "./secretFolders";
|
||||||
|
|||||||
@@ -71,12 +71,14 @@ export const useUpdateOrg = () => {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
name,
|
name,
|
||||||
authEnforced,
|
authEnforced,
|
||||||
|
scimEnabled,
|
||||||
slug,
|
slug,
|
||||||
orgId
|
orgId
|
||||||
}) => {
|
}) => {
|
||||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||||
name,
|
name,
|
||||||
authEnforced,
|
authEnforced,
|
||||||
|
scimEnabled,
|
||||||
slug
|
slug
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type Organization = {
|
|||||||
createAt: string;
|
createAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
authEnforced: boolean;
|
authEnforced: boolean;
|
||||||
|
scimEnabled: boolean;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export type UpdateOrgDTO = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
authEnforced?: boolean;
|
authEnforced?: boolean;
|
||||||
|
scimEnabled?: boolean;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
useCreateScimToken,
|
||||||
|
useDeleteScimToken
|
||||||
|
} from "./mutations";
|
||||||
|
export { useGetScimTokens } from "./queries";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { scimKeys } from "./queries";
|
||||||
|
import {
|
||||||
|
CreateScimTokenDTO,
|
||||||
|
CreateScimTokenRes,
|
||||||
|
DeleteScimTokenDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const useCreateScimToken = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<CreateScimTokenRes, {}, CreateScimTokenDTO>({
|
||||||
|
mutationFn: async ({
|
||||||
|
organizationId,
|
||||||
|
description,
|
||||||
|
ttlDays
|
||||||
|
}) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", {
|
||||||
|
organizationId,
|
||||||
|
description,
|
||||||
|
ttlDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { organizationId }) => {
|
||||||
|
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteScimToken = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<CreateScimTokenRes, {}, DeleteScimTokenDTO>({
|
||||||
|
mutationFn: async ({ scimTokenId }) => {
|
||||||
|
const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { organizationId }) => {
|
||||||
|
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { ScimTokenData } from "./types";
|
||||||
|
|
||||||
|
export const scimKeys = {
|
||||||
|
getScimTokens: (orgId: string) => [{ orgId }, "organization-scim-token"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetScimTokens = (organizationId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: scimKeys.getScimTokens(organizationId),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (organizationId === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { scimTokens } } = await apiRequest.get<{ scimTokens: ScimTokenData[] }>(`/api/v1/scim/scim-tokens?organizationId=${organizationId}`);
|
||||||
|
|
||||||
|
return scimTokens;
|
||||||
|
},
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type ScimTokenData = {
|
||||||
|
id: string;
|
||||||
|
ttlDays: number;
|
||||||
|
description: string;
|
||||||
|
tokenSuffix: string;
|
||||||
|
orgId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateScimTokenDTO = {
|
||||||
|
organizationId: string;
|
||||||
|
description?: string;
|
||||||
|
ttlDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteScimTokenDTO = {
|
||||||
|
organizationId: string;
|
||||||
|
scimTokenId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateScimTokenRes = {
|
||||||
|
scimToken: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
import { organizationKeys } from "@app/hooks/api/organization/queries";
|
||||||
|
|
||||||
const ssoConfigKeys = {
|
const ssoConfigKeys = {
|
||||||
getSSOConfig: (orgId: string) => [{ orgId }, "organization-saml-sso"] as const
|
getSSOConfig: (orgId: string) => [{ orgId }, "organization-saml-sso"] as const
|
||||||
@@ -82,8 +83,12 @@ export const useUpdateSSOConfig = () => {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess(_, dto) {
|
onSuccess(_, { organizationId, isActive }) {
|
||||||
queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(dto.organizationId));
|
if (isActive === false) {
|
||||||
|
queryClient.invalidateQueries(organizationKeys.getUserOrganizations);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(organizationId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type SubscriptionPlan = {
|
|||||||
workspacesUsed: number;
|
workspacesUsed: number;
|
||||||
environmentLimit: number;
|
environmentLimit: number;
|
||||||
samlSSO: boolean;
|
samlSSO: boolean;
|
||||||
|
scim: boolean;
|
||||||
status:
|
status:
|
||||||
| "incomplete"
|
| "incomplete"
|
||||||
| "incomplete_expired"
|
| "incomplete_expired"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const OrgMembersSection = () => {
|
|||||||
const handleAddMemberModal = () => {
|
const handleAddMemberModal = () => {
|
||||||
if (currentOrg?.authEnforced) {
|
if (currentOrg?.authEnforced) {
|
||||||
createNotification({
|
createNotification({
|
||||||
text: "You cannot invite users when org-level auth is configured for your organization",
|
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
|
||||||
type: "error"
|
type: "error"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -231,6 +231,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
|||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (currentOrg?.authEnforced) {
|
||||||
|
createNotification({
|
||||||
|
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handlePopUpOpen("removeMember", { orgMembershipId, email });
|
handlePopUpOpen("removeMember", { orgMembershipId, email });
|
||||||
}}
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
|||||||
subtitle: "Define organization level SSO requirements",
|
subtitle: "Define organization level SSO requirements",
|
||||||
icon: faSignIn,
|
icon: faSignIn,
|
||||||
formName: "sso"
|
formName: "sso"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "SCIM",
|
||||||
|
subtitle: "Define organization level SCIM requirements",
|
||||||
|
icon: faUsers,
|
||||||
|
formName: "scim"
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const formSchema = z.object({
|
|||||||
"incident-contact": generalPermissionSchema,
|
"incident-contact": generalPermissionSchema,
|
||||||
"secret-scanning": generalPermissionSchema,
|
"secret-scanning": generalPermissionSchema,
|
||||||
sso: generalPermissionSchema,
|
sso: generalPermissionSchema,
|
||||||
|
scim: generalPermissionSchema,
|
||||||
billing: generalPermissionSchema,
|
billing: generalPermissionSchema,
|
||||||
identity: generalPermissionSchema
|
identity: generalPermissionSchema
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
|||||||
import { withPermission } from "@app/hoc";
|
import { withPermission } from "@app/hoc";
|
||||||
|
|
||||||
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
|
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
|
||||||
|
import { OrgScimSection } from "./OrgSCIMSection";
|
||||||
import { OrgSSOSection } from "./OrgSSOSection";
|
import { OrgSSOSection } from "./OrgSSOSection";
|
||||||
|
|
||||||
export const OrgAuthTab = withPermission(
|
export const OrgAuthTab = withPermission(
|
||||||
@@ -10,6 +11,7 @@ export const OrgAuthTab = withPermission(
|
|||||||
<div>
|
<div>
|
||||||
<OrgGeneralAuthSection />
|
<OrgGeneralAuthSection />
|
||||||
<OrgSSOSection />
|
<OrgSSOSection />
|
||||||
|
<OrgScimSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { Switch } from "@app/components/v2";
|
import {
|
||||||
|
Switch,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
import {
|
import {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
OrgPermissionSubjects,
|
OrgPermissionSubjects,
|
||||||
useOrganization
|
useOrganization,
|
||||||
|
useSubscription
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { useLogoutUser,useUpdateOrg } from "@app/hooks/api";
|
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
|
||||||
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
export const OrgGeneralAuthSection = () => {
|
export const OrgGeneralAuthSection = () => {
|
||||||
const { createNotification } = useNotificationContext();
|
const { createNotification } = useNotificationContext();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
|
||||||
const { mutateAsync } = useUpdateOrg();
|
const { mutateAsync } = useUpdateOrg();
|
||||||
|
|
||||||
@@ -19,6 +28,10 @@ export const OrgGeneralAuthSection = () => {
|
|||||||
const handleEnforceOrgAuthToggle = async (value: boolean) => {
|
const handleEnforceOrgAuthToggle = async (value: boolean) => {
|
||||||
try {
|
try {
|
||||||
if (!currentOrg?.id) return;
|
if (!currentOrg?.id) return;
|
||||||
|
if (!subscription?.samlSSO) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
orgId: currentOrg?.id,
|
orgId: currentOrg?.id,
|
||||||
@@ -39,7 +52,7 @@ export const OrgGeneralAuthSection = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
createNotification({
|
createNotification({
|
||||||
text: `Failed to ${value ? "enforce" : "un-enforce"} org-level auth`,
|
text: (err as { response: { data: { message: string; }}}).response.data.message,
|
||||||
type: "error"
|
type: "error"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,6 +73,11 @@ export const OrgGeneralAuthSection = () => {
|
|||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</OrgPermissionCan>
|
</OrgPermissionCan>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Switch,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
OrgPermissionActions,
|
||||||
|
OrgPermissionSubjects,
|
||||||
|
useOrganization,
|
||||||
|
useSubscription} from "@app/context";
|
||||||
|
import { useUpdateOrg } from "@app/hooks/api";
|
||||||
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
import { ScimTokenModal } from "./ScimTokenModal";
|
||||||
|
|
||||||
|
export const OrgScimSection = () => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
|
"scimToken",
|
||||||
|
"deleteScimToken",
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const { mutateAsync } = useUpdateOrg();
|
||||||
|
|
||||||
|
const addScimTokenBtnClick = () => {
|
||||||
|
if (subscription?.scim) {
|
||||||
|
handlePopUpOpen("scimToken");
|
||||||
|
} else {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnableSCIMToggle = async (value: boolean) => {
|
||||||
|
try {
|
||||||
|
if (!currentOrg?.id) return;
|
||||||
|
if (!subscription?.scim) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
orgId: currentOrg?.id,
|
||||||
|
scimEnabled: value
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `Successfully ${value ? "enabled" : "disabled"} SCIM provisioning`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
text: (err as { response: { data: { message: string; }}}).response.data.message,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-8 flex items-center">
|
||||||
|
<h2 className="flex-1 text-xl font-semibold text-white">SCIM Configuration</h2>
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={addScimTokenBtnClick}
|
||||||
|
colorSchema="secondary"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Manage SCIM Tokens
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Switch
|
||||||
|
id="enable-scim"
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
if (subscription?.scim) {
|
||||||
|
handleEnableSCIMToggle(value)
|
||||||
|
} else {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isChecked={currentOrg?.scimEnabled ?? false}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Enable SCIM Provisioning
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
<ScimTokenModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
EmptyState,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useCreateScimToken,
|
||||||
|
useDeleteScimToken,
|
||||||
|
useGetScimTokens} from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
description: yup.string(),
|
||||||
|
ttlDays: yup.string().required("TTL is required")
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["scimToken", "deleteScimToken"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteScimToken"]>,
|
||||||
|
data?: {
|
||||||
|
scimTokenId: string;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
handlePopUpToggle: (
|
||||||
|
popUpName: keyof UsePopUpState<
|
||||||
|
["scimToken", "deleteScimToken"]
|
||||||
|
>,
|
||||||
|
state?: boolean
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScimTokenModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: Props) => {
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
|
||||||
|
const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false);
|
||||||
|
const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useGetScimTokens(currentOrg?.id ?? "");
|
||||||
|
const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken();
|
||||||
|
const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
description: "",
|
||||||
|
ttlDays: "365"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (isScimUrlCopied) {
|
||||||
|
timer = setTimeout(() => setIsScimUrlCopied.off(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScimTokenCopied) {
|
||||||
|
timer = setTimeout(() => setIsScimTokenCopied.off(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isScimTokenCopied, isScimUrlCopied]);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ description, ttlDays }: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentOrg?.id) return;
|
||||||
|
|
||||||
|
const { scimToken } = await createScimTokenMutateAsync({
|
||||||
|
organizationId: currentOrg.id,
|
||||||
|
description,
|
||||||
|
ttlDays: Number(ttlDays)
|
||||||
|
});
|
||||||
|
|
||||||
|
setToken(scimToken);
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully created SCIM token",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create SCIM token",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
|
||||||
|
try {
|
||||||
|
if (!currentOrg?.id) return;
|
||||||
|
|
||||||
|
await deleteScimTokenMutateAsync({
|
||||||
|
organizationId: currentOrg.id,
|
||||||
|
scimTokenId
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpToggle("deleteScimToken", false);
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully deleted SCIM token",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to delete SCIM token",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasToken = Boolean(token);
|
||||||
|
const scimUrl = `${window.origin}/api/v1/scim`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.scimToken?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("scimToken", isOpen);
|
||||||
|
reset();
|
||||||
|
setToken("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title="Manage SCIM credentials">
|
||||||
|
<h2 className="mb-4">SCIM URL</h2>
|
||||||
|
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{scimUrl}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(scimUrl);
|
||||||
|
setIsScimUrlCopied.on();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isScimUrlCopied ? faCheck : faCopy} />
|
||||||
|
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||||
|
{t("common.click-to-copy")}
|
||||||
|
</span>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4">New SCIM Token</h2>
|
||||||
|
{hasToken ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p>We will only show this token once</p>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
setToken("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{token}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
setIsScimTokenCopied.on();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isScimTokenCopied ? faCheck : faCopy} />
|
||||||
|
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||||
|
{t("common.click-to-copy")}
|
||||||
|
</span>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="description"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Description (optional)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="Description" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue="365"
|
||||||
|
name="ttlDays"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="TTL (days)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<Input {...field} placeholder="0" type="number" min="0" step="1" />
|
||||||
|
<Button
|
||||||
|
className="ml-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<h2 className="mb-4">SCIM Tokens</h2>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Description</Th>
|
||||||
|
<Th>Expires At</Th>
|
||||||
|
<Th>Created At</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={4} innerKey="org-scim-tokens" />}
|
||||||
|
{!isLoading &&
|
||||||
|
data &&
|
||||||
|
data.length > 0 &&
|
||||||
|
data.map(
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
ttlDays,
|
||||||
|
createdAt
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
let expiresAt;
|
||||||
|
if (ttlDays > 0) {
|
||||||
|
expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
|
||||||
|
<Td>{description === "" ? "-" : description}</Td>
|
||||||
|
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"}</Td>
|
||||||
|
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
|
||||||
|
<Td>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("deleteScimToken", {
|
||||||
|
scimTokenId: id
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="lg"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</IconButton>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{!isLoading && data && data?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={4}>
|
||||||
|
<EmptyState
|
||||||
|
title="No SCIM tokens have been created yet"
|
||||||
|
icon={faKey}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteScimToken.isOpen}
|
||||||
|
title="Are you sure want to delete the SCIM token?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("scimToken", isOpen)}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={() => {
|
||||||
|
|
||||||
|
const deleteScimTokenData = popUp?.deleteScimToken?.data as {
|
||||||
|
scimTokenId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||