Complete preliminary SCIM fns, add permissioning to SCIM, add docs for SCIM

This commit is contained in:
Tuan Dang
2024-02-18 18:50:23 -08:00
parent e15ed4cc58
commit 7a65f8c837
61 changed files with 1259 additions and 552 deletions
+1 -5
View File
@@ -265,11 +265,7 @@ declare module "knex/types/tables" {
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate TIdentityProjectMembershipsUpdate
>; >;
[TableName.ScimToken]: Knex.CompositeTableType< [TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
TScimTokens,
TScimTokensInsert,
TScimTokensUpdate
>;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType< [TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies, TSecretApprovalPolicies,
TSecretApprovalPoliciesInsert, TSecretApprovalPoliciesInsert,
@@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.ScimToken))) { if (!(await knex.schema.hasTable(TableName.ScimToken))) {
await knex.schema.createTable(TableName.ScimToken, (t) => { await knex.schema.createTable(TableName.ScimToken, (t) => {
t.string("id", 36).primary().defaultTo(knex.fn.uuid()); t.string("id", 36).primary().defaultTo(knex.fn.uuid());
t.bigInteger("ttl").defaultTo(15552000).notNullable(); // 180 days second t.bigInteger("ttlDays").defaultTo(365).notNullable();
t.string("description").notNullable(); t.string("description").notNullable();
t.uuid("orgId").notNullable(); t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
@@ -15,10 +15,17 @@ export async function up(knex: Knex): Promise<void> {
}); });
} }
await knex.schema.alterTable(TableName.Organization, (t) => {
t.boolean("scimEnabled").defaultTo(false);
});
await createOnUpdateTrigger(knex, TableName.ScimToken); await createOnUpdateTrigger(knex, TableName.ScimToken);
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.ScimToken); await knex.schema.dropTableIfExists(TableName.ScimToken);
await dropOnUpdateTrigger(knex, TableName.ScimToken); await dropOnUpdateTrigger(knex, TableName.ScimToken);
await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("scimEnabled");
});
} }
+2 -1
View File
@@ -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>;
+2 -2
View File
@@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models";
export const ScimTokensSchema = z.object({ export const ScimTokensSchema = z.object({
id: z.string(), id: z.string(),
ttl: z.coerce.number().default(15552000), ttlDays: z.coerce.number().default(365),
description: z.string(), description: z.string(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date()
}); });
export type TScimTokens = z.infer<typeof ScimTokensSchema>; export type TScimTokens = z.infer<typeof ScimTokensSchema>;
+283 -191
View File
@@ -1,195 +1,19 @@
import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { ScimTokensSchema } from "@app/db/schemas"; import { ScimTokensSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
export const registerScimRouter = async (server: FastifyZodProvider) => { export const registerScimRouter = async (server: FastifyZodProvider) => {
server.route({ server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
url: "/", try {
method: "GET", const strBody = body instanceof Buffer ? body.toString() : body;
schema: {
params: z.object({}),
response: {
200: z.object({})
}
},
// onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => {
return {
hello: "world"
};
}
});
server.route({ const json: unknown = JSON.parse(strBody); // TODO: update
url: "/Users", done(null, json);
method: "GET", } catch (err) {
schema: { const error = err as Error;
querystring: z.object({ done(error, undefined);
startIndex: z.coerce.number().default(1),
count: z.coerce.number().default(20),
filter: z.string().trim().optional()
}),
response: {
200: z.object({ // TODO: audit the response
Resources: z.array(z.object({
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})),
itemsPerPage: z.number(),
schemas: z.array(z.string()),
startIndex: z.number(),
totalResults: z.number(),
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.listUsers({
offset: req.query.startIndex,
limit: req.query.count,
filter: req.query.filter
});
return res;
}
});
server.route({
url: "/Users/:userId",
method: "GET",
schema: {
params: z.object({
userId: z.string().trim()
}),
response: {
201: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.getUser(req.params.userId);
return res;
}
});
server.route({
url: "/Users",
method: "POST",
schema: {
body: z.object({
schemas: z.array(z.string()),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
// locale: z.string().trim(),
// externalId: z.string().trim(),
// groups: z.array(z.object({
// value: z.string().trim()
// })),
// password: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req, reply) => {
const user = await req.server.services.scim.createUser({
email: req.body.emails[0].value,
firstName: req.body.name.givenName,
lastName: req.body.name.familyName,
orgId: req.permission.orgId as string
});
reply.code(201);
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PATCH",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's attr
return {};
}
});
server.route({
url: "/Users/:userId",
method: "PUT",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's profile
return {};
} }
}); });
@@ -201,7 +25,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({ body: z.object({
organizationId: z.string().trim(), organizationId: z.string().trim(),
description: z.string().trim().default(""), description: z.string().trim().default(""),
ttl: z.number().min(0).default(0) ttlDays: z.number().min(0).default(0)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -211,9 +35,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}, },
handler: async (req) => { handler: async (req) => {
const { scimToken } = await server.services.scim.createScimToken({ const { scimToken } = await server.services.scim.createScimToken({
organizationId: req.body.organizationId, actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
orgId: req.body.organizationId,
description: req.body.description, description: req.body.description,
ttl: req.body.ttl ttlDays: req.body.ttlDays
}); });
return { scimToken }; return { scimToken };
@@ -235,7 +62,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
} }
}, },
handler: async (req) => { handler: async (req) => {
const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId); const scimTokens = await server.services.scim.listScimTokens({
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
orgId: req.query.organizationId
});
return { scimTokens }; return { scimTokens };
} }
}); });
@@ -255,8 +88,267 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
} }
}, },
handler: async (req) => { handler: async (req) => {
const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId); const scimToken = await server.services.scim.deleteScimToken({
scimTokenId: req.params.scimTokenId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId
});
return { scimToken }; return { scimToken };
} }
}); });
// SCIM server endpoints
server.route({
url: "/Users",
method: "GET",
schema: {
querystring: z.object({
startIndex: z.coerce.number().default(1),
count: z.coerce.number().default(20),
filter: z.string().trim().optional()
}),
response: {
200: z.object({
Resources: z.array(
z.object({
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
),
itemsPerPage: z.number(),
schemas: z.array(z.string()),
startIndex: z.number(),
totalResults: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const users = await req.server.services.scim.listScimUsers({
offset: req.query.startIndex,
limit: req.query.count,
filter: req.query.filter,
orgId: req.permission.orgId as string
});
return users;
}
});
server.route({
url: "/Users/:userId",
method: "GET",
schema: {
params: z.object({
userId: z.string().trim()
}),
response: {
201: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.getScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string
});
return user;
}
});
server.route({
url: "/Users",
method: "POST",
schema: {
body: z.object({
schemas: z.array(z.string()),
userName: z.string().trim().email(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
// emails: z.array( // optional?
// z.object({
// primary: z.boolean(),
// value: z.string().email(),
// type: z.string().trim()
// })
// ),
// displayName: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim().email(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.createScimUser({
email: req.body.userName,
firstName: req.body.name.givenName,
lastName: req.body.name.familyName,
orgId: req.permission.orgId as string
});
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PATCH",
schema: {
params: z.object({
userId: z.string().trim()
}),
body: z.object({
schemas: z.array(z.string()),
Operations: z.array(
z.object({
op: z.string().trim(),
path: z.string().trim().optional(),
value: z.union([
z.object({
active: z.boolean()
}),
z.string().trim()
])
})
)
}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.updateScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string,
operations: req.body.Operations
});
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PUT",
schema: {
params: z.object({
userId: z.string().trim()
}),
body: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
// emails: z.array(
// z.object({
// primary: z.boolean(),
// value: z.string().email(),
// type: z.string().trim()
// })
// ),
displayName: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.replaceScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string,
active: req.body.active
});
return user;
}
});
// server.route({
// url: "/Users/:userId",
// method: "DELETE",
// schema: {
// body: z.object({}),
// response: {
// 200: z.object({})
// }
// },
// onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
// handler: () => {
// // TODO: update a user's profile
// return {};
// }
// });
}; };
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
export type TCreateAuditLogDTO = { export type TCreateAuditLogDTO = {
event: Event; event: Event;
actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor; actor: UserActor | IdentityActor | ServiceActor;
orgId?: string; orgId?: string;
projectId?: string; projectId?: string;
} & BaseAuthData; } & BaseAuthData;
@@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
customAlerts: false, customAlerts: false,
auditLogs: false, auditLogs: false,
auditLogsRetentionDays: 0, auditLogsRetentionDays: 0,
samlSSO: true, samlSSO: false,
scim: true, scim: false,
status: null, status: null,
trial_end: null, trial_end: null,
has_used_trial: true, has_used_trial: true,
@@ -24,8 +24,8 @@ export type TFeatureSet = {
customAlerts: false; customAlerts: false;
auditLogs: false; auditLogs: false;
auditLogsRetentionDays: 0; auditLogsRetentionDays: 0;
samlSSO: true; samlSSO: false;
scim: true; 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;
}; };
+58
View File
@@ -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
}
};
};
+345 -156
View File
@@ -1,61 +1,71 @@
import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
OrgMembershipRole,
OrgMembershipStatus
} from "@app/db/schemas";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { getConfig } from "@app/lib/config/env"; import { 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 { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembership } from "@app/services/org/org-fns";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { buildScimUser, buildScimUserList } from "./scim-fns";
import { import {
TCreateScimTokenDTO, TCreateScimTokenDTO,
TListScimUsersDTO,
TListScimUsersRes,
TCreateScimUserDTO, TCreateScimUserDTO,
TScimTokenJwtPayload TDeleteScimTokenDTO,
TGetScimUserDTO,
TListScimUsers,
TListScimUsersDTO,
TReplaceScimUserDTO,
TScimTokenJwtPayload,
TUpdateScimUserDTO
} from "./scim-types"; } from "./scim-types";
import {
createScimUser,
TScimUser,
} from "@app/lib/scim";
import { UnauthorizedError, ScimRequestError } from "@app/lib/errors";
type TScimServiceFactoryDep = { type TScimServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; // TODO: pick types
scimDAL: TScimDALFactory; // TODO: pick scimDAL: TScimDALFactory; // TODO: pick
userDAL: TUserDALFactory; // TODO: pick userDAL: TUserDALFactory; // TODO: pick
orgDAL: TOrgDALFactory; // TODO: pick orgDAL: TOrgDALFactory; // TODO: pick
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService;
}; };
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>; export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
export const scimServiceFactory = ({ export const scimServiceFactory = ({
licenseService, licenseService,
scimDAL, scimDAL,
userDAL, userDAL,
orgDAL, orgDAL,
permissionService permissionService,
smtpService
}: TScimServiceFactoryDep) => { }: TScimServiceFactoryDep) => {
const createScimToken = async ({ const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => {
organizationId, const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
description, ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
ttl
}: TCreateScimTokenDTO) => { 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 appCfg = getConfig();
// TODO: permission stuff
const scimTokenData = await scimDAL.create({ const scimTokenData = await scimDAL.create({
orgId: organizationId, orgId,
description, description,
ttl ttlDays
}); });
const scimToken = jwt.sign( const scimToken = jwt.sign(
{ {
scimTokenId: scimTokenData.id, scimTokenId: scimTokenData.id,
@@ -63,167 +73,346 @@ export const scimServiceFactory = ({
}, },
appCfg.AUTH_SECRET appCfg.AUTH_SECRET
); );
return { scimToken }
}
const getScimTokens = async (organizationId: string) => { return { scimToken };
const scimTokens = await scimDAL.find({ orgId: organizationId }); };
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; return scimTokens;
} };
const deleteScimToken = async (scimTokenId: string) => { const deleteScimToken = async ({ scimTokenId, actor, actorId, actorOrgId }: TDeleteScimTokenDTO) => {
const scimToken = await scimDAL.deleteById(scimTokenId); 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; return scimToken;
} };
// scim server endpoints // 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());
const listUsers = async ({
offset,
limit,
filter
}: TListScimUsersDTO): Promise<TListScimUsersRes> => {
const parseFilter = (filter: string | undefined) => {
if (!filter) return {};
const [parsedName, parsedValue] = filter.split("eq").map(s => s.trim());
let attributeName = parsedName; let attributeName = parsedName;
if (parsedName === "userName") { // note if (parsedName === "userName") {
attributeName = "email"; attributeName = "email";
} }
return { [attributeName]: parsedValue }; return { [attributeName]: parsedValue };
}; };
const findOpts = { const findOpts = {
...(offset && { offset }), ...(offset && { offset }),
...(limit && { limit }), ...(limit && { limit })
}; };
const users = await userDAL.find(parseFilter(filter), findOpts); const users = await orgDAL.findMembership(
{
let resources: TScimUser[] = []; orgId,
...parseFilter(filter)
let scimResource: TListScimUsersRes = { // note: type },
Resources: [], findOpts
itemsPerPage: limit, );
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: offset, const scimUsers = users.map(({ userId, firstName, lastName, email }) =>
totalResults: users.length buildScimUser({
}; userId: userId ?? "",
firstName: firstName ?? "",
users.forEach((user) => { lastName: lastName ?? "",
let scimUser = createScimUser({ email,
userId: user.id, active: true
firstName: user.firstName as string, })
lastName: user.lastName as string, );
email: user.email
}); return buildScimUserList({
resources.push(scimUser); scimUsers,
offset,
limit
}); });
};
scimResource.Resources = resources; const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
const [membership] = await orgDAL
return scimResource; .findMembership({
} userId,
orgId
const getUser = async (userId: string) => { })
// TODO: check out SCIM-specific errors .catch(() => {
throw new ScimRequestError({
let user; detail: "User not found",
try { status: 404
user = await userDAL.findById(userId); });
} catch (error) { });
interface PostgresError extends Error { if (!membership)
error: { throw new ScimRequestError({
code: string;
}
}
const dbError = error as PostgresError;
if (dbError.error.code === "22P02") throw new ScimRequestError({
detail: "User not found", detail: "User not found",
status: 404 status: 404
}); });
throw error; if (!membership.scimEnabled)
} throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
if (!user) throw new ScimRequestError({ status: 403
detail: "User not found", });
status: 404
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active: true
}); });
};
return createScimUser({
userId: user.id, const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => {
firstName: user.firstName as string, const org = await orgDAL.findById(orgId);
lastName: user.lastName as string,
email: user.email if (!org)
}); throw new ScimRequestError({
} detail: "Organization not found",
status: 404
const createUser = async ({ });
firstName,
lastName, if (!org.scimEnabled)
email, throw new ScimRequestError({
orgId detail: "SCIM is disabled for the organization",
}: TCreateScimUserDTO) => { status: 403
});
let user = await userDAL.findOne({ let user = await userDAL.findOne({
email email
}); });
if (user) throw new ScimRequestError({ if (user) {
detail: "User already exists in the database", await userDAL.transaction(async (tx) => {
status: 409 const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
if (orgMembership)
throw new ScimRequestError({
detail: "User already exists in the database",
status: 409
});
if (!orgMembership) {
await orgDAL.createMembership(
{
userId: user.id,
orgId,
inviteEmail: email,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
},
tx
);
}
});
} else {
user = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create(
{
email,
firstName,
lastName,
authMethods: [AuthMethod.EMAIL]
},
tx
);
await orgDAL.createMembership(
{
inviteEmail: email,
orgId,
userId: newUser.id,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
},
tx
);
return newUser;
});
}
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.ScimUserProvisioned,
subjectLine: "Infisical organization invitation",
recipients: [email],
substitutions: {
organizationName: org.name,
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
}
}); });
user = await userDAL.transaction(async (tx) => { return buildScimUser({
const newUser = await userDAL.create(
{
email,
firstName,
lastName,
authMethods: [AuthMethod.EMAIL]
},
tx
);
await orgDAL.createMembership({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
});
return newUser;
});
return createScimUser({
userId: user.id, userId: user.id,
firstName: user.firstName as string, firstName: user.firstName as string,
lastName: user.lastName as string, lastName: user.lastName as string,
email: user.email email: user.email,
active: true
}); });
} };
const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => {
const [membership] = await orgDAL
.findMembership({
userId,
orgId
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
if (!membership)
throw new ScimRequestError({
detail: "User not found",
status: 404
});
if (!membership.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
let active = true;
operations.forEach((operation) => {
if (operation.op.toLowerCase() === "replace") {
if (operation.path === "active" && operation.value === "False") {
// azure scim op format
active = false;
} else if (typeof operation.value === "object" && operation.value.active === false) {
// okta scim op format
active = false;
}
}
});
if (!active) {
await deleteOrgMembership({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL
});
}
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active
});
};
const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => {
const [membership] = await orgDAL
.findMembership({
userId,
orgId
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
if (!membership)
throw new ScimRequestError({
detail: "User not found",
status: 404
});
if (!membership.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
if (!active) {
// tx
await deleteOrgMembership({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL
});
}
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active
});
};
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => { const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
// TODO: check expiry
const scimToken = await scimDAL.findById(token.scimTokenId); const scimToken = await scimDAL.findById(token.scimTokenId);
if (!scimToken) throw new UnauthorizedError(); if (!scimToken) throw new UnauthorizedError();
const { ttlDays, createdAt } = scimToken;
// ttl check
if (Number(ttlDays) > 0) {
const currentDate = new Date();
const scimTokenCreatedAt = new Date(createdAt);
const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400;
const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds);
if (currentDate > expirationDate)
throw new ScimRequestError({
detail: "The access token expired",
status: 401
});
}
return { scimTokenId: scimToken.id, orgId: scimToken.orgId }; return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
} };
return { return {
createScimToken, createScimToken,
getScimTokens, listScimTokens,
deleteScimToken, deleteScimToken,
listUsers, listScimUsers,
getUser, getScimUser,
createUser, createScimUser,
updateScimUser,
replaceScimUser,
fnValidateScimToken fnValidateScimToken
}; };
}; };
+75 -29
View File
@@ -1,41 +1,87 @@
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { TScimUser } from "@app/lib/scim";
export type TCreateScimTokenDTO = { export type TCreateScimTokenDTO = {
organizationId: string; description: string;
description: string; ttlDays: number;
ttl: number; } & TOrgPermission;
}
// TODO: add org permissions export type TDeleteScimTokenDTO = {
// & Omit<TOrgPermission, "orgId">; scimTokenId: string;
} & Omit<TOrgPermission, "orgId">;
// SCIM server endpoint types
export type TListScimUsersDTO = { export type TListScimUsersDTO = {
offset: number; offset: number;
limit: number; limit: number;
filter?: string; filter?: string;
} orgId: string;
};
export type TListScimUsersRes = { // check naming here export type TListScimUsers = {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
totalResults: number; totalResults: number;
Resources: TScimUser[]; Resources: TScimUser[];
itemsPerPage: number; itemsPerPage: number;
startIndex: number; startIndex: number;
} };
export type TGetScimUserDTO = {
userId: string;
orgId: string;
};
export type TCreateScimUserDTO = { export type TCreateScimUserDTO = {
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
orgId: string; orgId: string;
} };
export type TCreateScimUserRes = { export type TUpdateScimUserDTO = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"] 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 = { export type TScimTokenJwtPayload = {
scimTokenId: string; scimTokenId: string;
authTokenType: 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;
};
};
+17 -2
View File
@@ -61,12 +61,27 @@ export class BadRequestError extends Error {
export class ScimRequestError extends Error { export class ScimRequestError extends Error {
name: string; name: string;
schemas: string[]; schemas: string[];
detail: string; detail: string;
status: number; status: number;
error: unknown; error: unknown;
constructor({ name, error, detail, status }: { message?: string; name?: string; error?: unknown, detail: string, status: number }) { constructor({
name,
error,
detail,
status
}: {
message?: string;
name?: string;
error?: unknown;
detail: string;
status: number;
}) {
super(detail ?? "The request is invalid"); super(detail ?? "The request is invalid");
this.name = name || "ScimRequestError"; this.name = name || "ScimRequestError";
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"]; this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
@@ -74,4 +89,4 @@ export class ScimRequestError extends Error {
this.detail = detail; this.detail = detail;
this.status = status; this.status = status;
} }
} }
-39
View File
@@ -1,39 +0,0 @@
import { TScimUser } from "./types";
export const createScimUser = ({
userId,
firstName,
lastName,
email
}: {
userId: string;
firstName: string;
lastName: string;
email: string;
}): TScimUser => {
let scimUser = {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": userId,
"userName": email,
"displayName": `${firstName} ${lastName}`,
"name": {
"givenName": firstName,
"middleName": null,
"familyName": lastName
},
"emails":
[{
"primary": true,
"value": email,
"type": "work"
}],
"active": true,
"groups": [],
"meta": {
"resourceType": "User",
"location": null
}
};
return scimUser;
}
-4
View File
@@ -1,4 +0,0 @@
export { TScimUser } from "./types";
export {
createScimUser
} from "./fns";
-23
View File
@@ -1,23 +0,0 @@
export type TScimUser = {
schemas: string[];
id: string;
userName: string;
displayName: string;
name: {
givenName: string;
middleName: null;
familyName: string;
};
emails: {
primary: boolean;
value: string;
type: string;
}[];
active: boolean;
groups: string[];
meta: {
resourceType: string;
location: null;
};
}
@@ -3,11 +3,11 @@ 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";
import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types";
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
export type TAuthMode = export type TAuthMode =
| { | {
+7 -1
View File
@@ -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, ScimRequestError } 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) => {
+11 -9
View File
@@ -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";
@@ -32,8 +34,6 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { TQueueServiceFactory } from "@app/queue"; import { TQueueServiceFactory } from "@app/queue";
import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
@@ -191,13 +191,7 @@ export const registerRoutes = async (
trustedIpDAL, trustedIpDAL,
permissionService permissionService
}); });
const scimService = scimServiceFactory({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService
});
const auditLogQueue = auditLogQueueServiceFactory({ const auditLogQueue = auditLogQueueServiceFactory({
auditLogDAL, auditLogDAL,
queueService, queueService,
@@ -220,6 +214,14 @@ export const registerRoutes = async (
samlConfigDAL, samlConfigDAL,
licenseService licenseService
}); });
const scimService = scimServiceFactory({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService,
smtpService
});
const telemetryService = telemetryServiceFactory(); const telemetryService = telemetryServiceFactory();
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
@@ -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({
+8 -1
View File
@@ -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) {
+21
View File
@@ -0,0 +1,21 @@
import { TOrgDALFactory } from "@app/services/org/org-dal";
type TDeleteOrgMembership = {
orgMembershipId: string;
orgId: string;
orgDAL: TOrgDALFactory;
};
export const deleteOrgMembership = async ({ orgMembershipId, orgId, orgDAL }: TDeleteOrgMembership) => {
// TODO: improve this implementation
// delete
const m2 = await orgDAL.transaction(async (tx) => {
const m1 = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
// const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
// delete project memberships
return m1;
});
return m2;
};
+20 -3
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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.
![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png)
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Azure.
![SCIM create token](/images/platform/scim/scim-create-token.png)
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Azure.
![SCIM copy token](/images/platform/scim/scim-copy-token.png)
</Step>
<Step title="Configure SCIM in Azure">
In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**.
![SCIM Azure](/images/platform/scim/azure/scim-azure-get-started.png)
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.
![SCIM Azure](/images/platform/scim/azure/scim-azure-config.png)
After you hit **Save**, select **Provision Microsoft Entra ID Users** under the **Mappings** subsection.
![SCIM Azure](/images/platform/scim/azure/scim-azure-select-user-mappings.png)
Next, adjust the mappings so you have them configured as below:
![SCIM Azure](/images/platform/scim/azure/scim-azure-user-mappings.png)
Finally, head to your Enterprise Application > Provisioning and set the **Provisioning Status** to **On**.
![SCIM Azure](/images/platform/scim/azure/scim-azure-provisioning-status.png)
Alternatively, you can go to **Overview** and press **Start provisioning** to have Azure start provisioning/deprovisioning users to Infisical.
![SCIM Azure](/images/platform/scim/azure/scim-azure-start-provisioning.png)
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.
![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png)
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for JumpCloud.
![SCIM create token](/images/platform/scim/scim-create-token.png)
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in JumpCloud.
![SCIM copy token](/images/platform/scim/scim-copy-token.png)
</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**.
![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png)
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.
![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-config.png)
Next, press **Test Connection** to check that SCIM is configured properly. Finally, press **Activate**
to have JumpCloud start provisioning/deprovisioning users to Infisical.
![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png)
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>
+70
View File
@@ -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.
![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png)
Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Okta.
![SCIM create token](/images/platform/scim/scim-create-token.png)
Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Okta.
![SCIM copy token](/images/platform/scim/scim-copy-token.png)
</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**.
![SCIM Okta](/images/platform/scim/okta/scim-okta-enable-provisioning.png)
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`.
![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png)
Under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1.
![SCIM Okta](/images/platform/scim/okta/scim-okta-auth.png)
Next, press **Test Connector Configuration** to check that SCIM is configured properly.
![SCIM Okta](/images/platform/scim/okta/scim-okta-test.png)
Next, head to Provisioning > To App and check the boxes labeled **Enable** for **Create Users**, **Update User Attributes**, and **Deactivate Users**.
![SCIM Okta](/images/platform/scim/okta/scim-okta-app-settings.png)
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>
+1 -1
View File
@@ -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.
@@ -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.
+1 -1
View File
@@ -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.
![Okta SAML initial configuration](../../../images/sso/okta/init-config.png) ![Okta SAML initial configuration](../../../images/sso/okta/init-config.png)
+3 -3
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

+9
View File
@@ -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]
@@ -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;
}; };
+2 -2
View File
@@ -13,12 +13,12 @@ export const useCreateScimToken = () => {
mutationFn: async ({ mutationFn: async ({
organizationId, organizationId,
description, description,
ttl ttlDays
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", { const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", {
organizationId, organizationId,
description, description,
ttl ttlDays
}); });
return data; return data;
+2 -2
View File
@@ -1,6 +1,6 @@
export type ScimTokenData = { export type ScimTokenData = {
id: string; id: string;
ttl: number; ttlDays: number;
description: string; description: string;
tokenSuffix: string; tokenSuffix: string;
orgId: string; orgId: string;
@@ -11,7 +11,7 @@ export type ScimTokenData = {
export type CreateScimTokenDTO = { export type CreateScimTokenDTO = {
organizationId: string; organizationId: string;
description?: string; description?: string;
ttl?: number; ttlDays?: number;
} }
export type DeleteScimTokenDTO = { export type DeleteScimTokenDTO = {
@@ -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 { usePopUp } from "@app/hooks/usePopUp";
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
export const OrgGeneralAuthSection = () => { export const OrgGeneralAuthSection = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpClose, 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,
@@ -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>
); );
} }
@@ -1,37 +1,34 @@
import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faPlus, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// 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 { import {
Button, Button,
IconButton,
Switch, Switch,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
// OrgPermissionActions, OrgPermissionActions,
// OrgPermissionSubjects, OrgPermissionSubjects,
useOrganization, useSubscription,
useSubscription useOrganization
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { ScimTokenModal } from "./ScimTokenModal"; import { ScimTokenModal } from "./ScimTokenModal";
import { useUpdateOrg } from "@app/hooks/api";
// TODO: add permissioning for enteprise SCIM
export const OrgScimSection = () => { export const OrgScimSection = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
// const { createNotification } = useNotificationContext();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"scimToken", "scimToken",
"deleteScimToken", "deleteScimToken",
"upgradePlan" "upgradePlan"
] as const); ] as const);
const [scimEnabled, setScimEnabled] = useState(false); // sync this with backend const { mutateAsync } = useUpdateOrg();
const addScimTokenBtnClick = () => { const addScimTokenBtnClick = () => {
if (subscription?.scim) { if (subscription?.scim) {
@@ -41,11 +38,29 @@ export const OrgScimSection = () => {
} }
} }
const handleSCIMToggle = (value: boolean) => { const handleEnableSCIMToggle = async (value: boolean) => {
try { try {
setScimEnabled(value); 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) { } catch (err) {
console.error(err); console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} SCIM provisioning`,
type: "error"
});
} }
} }
@@ -53,23 +68,35 @@ export const OrgScimSection = () => {
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center"> <div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">SCIM Configuration</h2> <h2 className="flex-1 text-xl font-semibold text-white">SCIM Configuration</h2>
<Button <OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
onClick={addScimTokenBtnClick} {(isAllowed) => (
colorSchema="secondary" <Button
// isDisabled={!isAllowed} onClick={addScimTokenBtnClick}
leftIcon={<FontAwesomeIcon icon={faPlus} />} colorSchema="secondary"
> isDisabled={!isAllowed}
Manage SCIM Tokens leftIcon={<FontAwesomeIcon icon={faPlus} />}
</Button> >
Manage SCIM Tokens
</Button>
)}
</OrgPermissionCan>
</div> </div>
<Switch <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
id="enable-scim" <Switch
onCheckedChange={(value) => handleSCIMToggle(value)} id="enable-scim"
isChecked={scimEnabled} onCheckedChange={(value) => {
isDisabled={false} if (subscription?.scim) {
> handleEnableSCIMToggle(value)
Enable SCIM Provisioning } else {
</Switch> handlePopUpOpen("upgradePlan");
}
}}
isChecked={currentOrg?.scimEnabled ?? false}
isDisabled={false}
>
Enable SCIM Provisioning
</Switch>
</OrgPermissionCan>
<ScimTokenModal <ScimTokenModal
popUp={popUp} popUp={popUp}
handlePopUpOpen={handlePopUpOpen} handlePopUpOpen={handlePopUpOpen}
@@ -78,7 +105,7 @@ export const OrgScimSection = () => {
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SCIM Provisioning if you switch to Infisical's Pro plan." text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan."
/> />
</div> </div>
); );
@@ -35,11 +35,9 @@ import {
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
// TODO: turn TTL into a select component
const schema = yup.object({ const schema = yup.object({
description: yup.string(), description: yup.string(),
ttl: yup.string() ttlDays: yup.string().required("TTL is required")
}); });
export type FormData = yup.InferType<typeof schema>; export type FormData = yup.InferType<typeof schema>;
@@ -86,7 +84,7 @@ export const ScimTokenModal = ({
resolver: yupResolver(schema), resolver: yupResolver(schema),
defaultValues: { defaultValues: {
description: "", description: "",
ttl: "" ttlDays: "365"
} }
}); });
@@ -103,14 +101,14 @@ export const ScimTokenModal = ({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isScimTokenCopied, isScimUrlCopied]); }, [isScimTokenCopied, isScimUrlCopied]);
const onFormSubmit = async ({ description, ttl }: FormData) => { const onFormSubmit = async ({ description, ttlDays }: FormData) => {
try { try {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
const { scimToken } = await createScimTokenMutateAsync({ const { scimToken } = await createScimTokenMutateAsync({
organizationId: currentOrg.id, organizationId: currentOrg.id,
description, description,
ttl: Number(ttl) ttlDays: Number(ttlDays)
}); });
setToken(scimToken); setToken(scimToken);
@@ -130,19 +128,12 @@ export const ScimTokenModal = ({
const onDeleteScimTokenSubmit = async (scimTokenId: string) => { const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
try { try {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
await deleteScimTokenMutateAsync({ await deleteScimTokenMutateAsync({
organizationId: currentOrg.id, organizationId: currentOrg.id,
scimTokenId scimTokenId
}); });
// TODO: find alt way
// if (token.startsWith(clientSecretPrefix)) {
// reset();
// setToken("");
// }
handlePopUpToggle("deleteScimToken", false); handlePopUpToggle("deleteScimToken", false);
@@ -242,11 +233,11 @@ export const ScimTokenModal = ({
/> />
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue="365"
name="ttl" name="ttlDays"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="TTL (seconds - optional)" label="TTL (days)"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
@@ -287,19 +278,19 @@ export const ScimTokenModal = ({
({ ({
id, id,
description, description,
ttl, ttlDays,
createdAt createdAt
}) => { }) => {
let expiresAt; let expiresAt;
if (ttl > 0) { if (ttlDays > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + ttl * 1000); expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400);
} }
return ( return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}> <Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td> <Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}</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>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
<Td> <Td>
<IconButton <IconButton