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,
TIdentityProjectMembershipsUpdate
>;
[TableName.ScimToken]: Knex.CompositeTableType<
TScimTokens,
TScimTokensInsert,
TScimTokensUpdate
>;
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies,
TSecretApprovalPoliciesInsert,
@@ -7,7 +7,7 @@ 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("ttl").defaultTo(15552000).notNullable(); // 180 days second
t.bigInteger("ttlDays").defaultTo(365).notNullable();
t.string("description").notNullable();
t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
@@ -15,10 +15,17 @@ export async function up(knex: Knex): Promise<void> {
});
}
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");
});
}
+2 -1
View File
@@ -14,7 +14,8 @@ export const OrganizationsSchema = z.object({
slug: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
authEnforced: z.boolean().default(false).nullable().optional()
authEnforced: z.boolean().default(false).nullable().optional(),
scimEnabled: z.boolean().default(false).nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
+2 -2
View File
@@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models";
export const ScimTokensSchema = z.object({
id: z.string(),
ttl: z.coerce.number().default(15552000),
ttlDays: z.coerce.number().default(365),
description: z.string(),
orgId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
updatedAt: z.date()
});
export type TScimTokens = z.infer<typeof ScimTokensSchema>;
+283 -191
View File
@@ -1,195 +1,19 @@
import jwt from "jsonwebtoken";
import { z } from "zod";
import { ScimTokensSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerScimRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "GET",
schema: {
params: z.object({}),
response: {
200: z.object({})
}
},
// onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => {
return {
hello: "world"
};
}
});
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
try {
const strBody = body instanceof Buffer ? body.toString() : body;
server.route({
url: "/Users",
method: "GET",
schema: {
querystring: z.object({
startIndex: z.coerce.number().default(1),
count: z.coerce.number().default(20),
filter: z.string().trim().optional()
}),
response: {
200: z.object({ // TODO: audit the response
Resources: z.array(z.object({
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})),
itemsPerPage: z.number(),
schemas: z.array(z.string()),
startIndex: z.number(),
totalResults: z.number(),
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.listUsers({
offset: req.query.startIndex,
limit: req.query.count,
filter: req.query.filter
});
return res;
}
});
server.route({
url: "/Users/:userId",
method: "GET",
schema: {
params: z.object({
userId: z.string().trim()
}),
response: {
201: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const res = await req.server.services.scim.getUser(req.params.userId);
return res;
}
});
server.route({
url: "/Users",
method: "POST",
schema: {
body: z.object({
schemas: z.array(z.string()),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
// locale: z.string().trim(),
// externalId: z.string().trim(),
// groups: z.array(z.object({
// value: z.string().trim()
// })),
// password: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req, reply) => {
const user = await req.server.services.scim.createUser({
email: req.body.emails[0].value,
firstName: req.body.name.givenName,
lastName: req.body.name.familyName,
orgId: req.permission.orgId as string
});
reply.code(201);
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PATCH",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's attr
return {};
}
});
server.route({
url: "/Users/:userId",
method: "PUT",
schema: {
body: z.object({}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
// TODO: update a user's profile
return {};
const json: unknown = JSON.parse(strBody); // TODO: update
done(null, json);
} catch (err) {
const error = err as Error;
done(error, undefined);
}
});
@@ -201,7 +25,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({
organizationId: z.string().trim(),
description: z.string().trim().default(""),
ttl: z.number().min(0).default(0)
ttlDays: z.number().min(0).default(0)
}),
response: {
200: z.object({
@@ -211,9 +35,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
},
handler: async (req) => {
const { scimToken } = await server.services.scim.createScimToken({
organizationId: req.body.organizationId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
orgId: req.body.organizationId,
description: req.body.description,
ttl: req.body.ttl
ttlDays: req.body.ttlDays
});
return { scimToken };
@@ -235,7 +62,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId);
const scimTokens = await server.services.scim.listScimTokens({
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
orgId: req.query.organizationId
});
return { scimTokens };
}
});
@@ -255,8 +88,267 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId);
const scimToken = await server.services.scim.deleteScimToken({
scimTokenId: req.params.scimTokenId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId
});
return { scimToken };
}
});
// SCIM server endpoints
server.route({
url: "/Users",
method: "GET",
schema: {
querystring: z.object({
startIndex: z.coerce.number().default(1),
count: z.coerce.number().default(20),
filter: z.string().trim().optional()
}),
response: {
200: z.object({
Resources: z.array(
z.object({
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
),
itemsPerPage: z.number(),
schemas: z.array(z.string()),
startIndex: z.number(),
totalResults: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const users = await req.server.services.scim.listScimUsers({
offset: req.query.startIndex,
limit: req.query.count,
filter: req.query.filter,
orgId: req.permission.orgId as string
});
return users;
}
});
server.route({
url: "/Users/:userId",
method: "GET",
schema: {
params: z.object({
userId: z.string().trim()
}),
response: {
201: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.getScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string
});
return user;
}
});
server.route({
url: "/Users",
method: "POST",
schema: {
body: z.object({
schemas: z.array(z.string()),
userName: z.string().trim().email(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
// emails: z.array( // optional?
// z.object({
// primary: z.boolean(),
// value: z.string().email(),
// type: z.string().trim()
// })
// ),
// displayName: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim().email(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.createScimUser({
email: req.body.userName,
firstName: req.body.name.givenName,
lastName: req.body.name.familyName,
orgId: req.permission.orgId as string
});
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PATCH",
schema: {
params: z.object({
userId: z.string().trim()
}),
body: z.object({
schemas: z.array(z.string()),
Operations: z.array(
z.object({
op: z.string().trim(),
path: z.string().trim().optional(),
value: z.union([
z.object({
active: z.boolean()
}),
z.string().trim()
])
})
)
}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.updateScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string,
operations: req.body.Operations
});
return user;
}
});
server.route({
url: "/Users/:userId",
method: "PUT",
schema: {
params: z.object({
userId: z.string().trim()
}),
body: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
// emails: z.array(
// z.object({
// primary: z.boolean(),
// value: z.string().email(),
// type: z.string().trim()
// })
// ),
displayName: z.string().trim(),
active: z.boolean()
}),
response: {
200: z.object({
schemas: z.array(z.string()),
id: z.string().trim(),
userName: z.string().trim(),
name: z.object({
familyName: z.string().trim(),
givenName: z.string().trim()
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string().email(),
type: z.string().trim()
})
),
displayName: z.string().trim(),
active: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => {
const user = await req.server.services.scim.replaceScimUser({
userId: req.params.userId,
orgId: req.permission.orgId as string,
active: req.body.active
});
return user;
}
});
// server.route({
// url: "/Users/:userId",
// method: "DELETE",
// schema: {
// body: z.object({}),
// response: {
// 200: z.object({})
// }
// },
// onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
// handler: () => {
// // TODO: update a user's profile
// return {};
// }
// });
};
@@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
export type TCreateAuditLogDTO = {
event: Event;
actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor;
actor: UserActor | IdentityActor | ServiceActor;
orgId?: string;
projectId?: string;
} & BaseAuthData;
@@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
customAlerts: false,
auditLogs: false,
auditLogsRetentionDays: 0,
samlSSO: true,
scim: true,
samlSSO: false,
scim: false,
status: null,
trial_end: null,
has_used_trial: true,
@@ -24,8 +24,8 @@ export type TFeatureSet = {
customAlerts: false;
auditLogs: false;
auditLogsRetentionDays: 0;
samlSSO: true;
scim: true;
samlSSO: false;
scim: false;
status: null;
trial_end: null;
has_used_trial: true;
@@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
Settings = "settings",
IncidentAccount = "incident-contact",
Sso = "sso",
Scim = "scim",
Billing = "billing",
SecretScanning = "secret-scanning",
Identity = "identity"
@@ -29,6 +30,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
@@ -69,6 +71,11 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
@@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({
updateQuery.certTag = certTag;
}
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
await orgDAL.updateById(orgId, { authEnforced: false });
await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false });
return ssoConfig;
};
+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 {
OrgMembershipRole,
OrgMembershipStatus
} from "@app/db/schemas";
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
import { TOrgPermission } from "@app/lib/types";
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembership } from "@app/services/org/org-fns";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { buildScimUser, buildScimUserList } from "./scim-fns";
import {
TCreateScimTokenDTO,
TListScimUsersDTO,
TListScimUsersRes,
TCreateScimUserDTO,
TScimTokenJwtPayload
TDeleteScimTokenDTO,
TGetScimUserDTO,
TListScimUsers,
TListScimUsersDTO,
TReplaceScimUserDTO,
TScimTokenJwtPayload,
TUpdateScimUserDTO
} from "./scim-types";
import {
createScimUser,
TScimUser,
} from "@app/lib/scim";
import { UnauthorizedError, ScimRequestError } from "@app/lib/errors";
type TScimServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
// TODO: pick types
scimDAL: TScimDALFactory; // TODO: pick
userDAL: TUserDALFactory; // TODO: pick
orgDAL: TOrgDALFactory; // TODO: pick
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService;
};
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
export const scimServiceFactory = ({
export const scimServiceFactory = ({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService
permissionService,
smtpService
}: TScimServiceFactoryDep) => {
const createScimToken = async ({
organizationId,
description,
ttl
}: TCreateScimTokenDTO) => {
const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
const plan = await licenseService.getPlan(orgId);
if (!plan.scim)
throw new BadRequestError({
message: "Failed to create a SCIM token due to plan restriction. Upgrade plan to create a SCIM token."
});
const appCfg = getConfig();
// TODO: permission stuff
const scimTokenData = await scimDAL.create({
orgId: organizationId,
orgId,
description,
ttl
ttlDays
});
const scimToken = jwt.sign(
{
scimTokenId: scimTokenData.id,
@@ -63,167 +73,346 @@ export const scimServiceFactory = ({
},
appCfg.AUTH_SECRET
);
return { scimToken }
}
const getScimTokens = async (organizationId: string) => {
const scimTokens = await scimDAL.find({ orgId: organizationId });
return { scimToken };
};
const listScimTokens = async ({ actor, actorId, actorOrgId, orgId }: TOrgPermission) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
const plan = await licenseService.getPlan(orgId);
if (!plan.scim)
throw new BadRequestError({
message: "Failed to get SCIM tokens due to plan restriction. Upgrade plan to get SCIM tokens."
});
const scimTokens = await scimDAL.find({ orgId });
return scimTokens;
}
const deleteScimToken = async (scimTokenId: string) => {
const scimToken = await scimDAL.deleteById(scimTokenId);
};
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
};
// 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;
if (parsedName === "userName") { // note
if (parsedName === "userName") {
attributeName = "email";
}
return { [attributeName]: parsedValue };
};
const findOpts = {
...(offset && { offset }),
...(limit && { limit }),
...(limit && { limit })
};
const users = await userDAL.find(parseFilter(filter), findOpts);
let resources: TScimUser[] = [];
let scimResource: TListScimUsersRes = { // note: type
Resources: [],
itemsPerPage: limit,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: offset,
totalResults: users.length
};
users.forEach((user) => {
let scimUser = createScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
});
resources.push(scimUser);
const users = await orgDAL.findMembership(
{
orgId,
...parseFilter(filter)
},
findOpts
);
const scimUsers = users.map(({ userId, firstName, lastName, email }) =>
buildScimUser({
userId: userId ?? "",
firstName: firstName ?? "",
lastName: lastName ?? "",
email,
active: true
})
);
return buildScimUserList({
scimUsers,
offset,
limit
});
};
scimResource.Resources = resources;
return scimResource;
}
const getUser = async (userId: string) => {
// TODO: check out SCIM-specific errors
let user;
try {
user = await userDAL.findById(userId);
} catch (error) {
const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
const [membership] = await orgDAL
.findMembership({
userId,
orgId
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
interface PostgresError extends Error {
error: {
code: string;
}
}
const dbError = error as PostgresError;
if (dbError.error.code === "22P02") throw new ScimRequestError({
if (!membership)
throw new ScimRequestError({
detail: "User not found",
status: 404
});
throw error;
}
if (!user) throw new ScimRequestError({
detail: "User not found",
status: 404
if (!membership.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active: true
});
return createScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
});
}
const createUser = async ({
firstName,
lastName,
email,
orgId
}: TCreateScimUserDTO) => {
};
const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => {
const org = await orgDAL.findById(orgId);
if (!org)
throw new ScimRequestError({
detail: "Organization not found",
status: 404
});
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
let user = await userDAL.findOne({
email
});
if (user) throw new ScimRequestError({
detail: "User already exists in the database",
status: 409
if (user) {
await userDAL.transaction(async (tx) => {
const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
if (orgMembership)
throw new ScimRequestError({
detail: "User already exists in the database",
status: 409
});
if (!orgMembership) {
await orgDAL.createMembership(
{
userId: user.id,
orgId,
inviteEmail: email,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
},
tx
);
}
});
} else {
user = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create(
{
email,
firstName,
lastName,
authMethods: [AuthMethod.EMAIL]
},
tx
);
await orgDAL.createMembership(
{
inviteEmail: email,
orgId,
userId: newUser.id,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
},
tx
);
return newUser;
});
}
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.ScimUserProvisioned,
subjectLine: "Infisical organization invitation",
recipients: [email],
substitutions: {
organizationName: org.name,
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
}
});
user = await userDAL.transaction(async (tx) => {
const newUser = await userDAL.create(
{
email,
firstName,
lastName,
authMethods: [AuthMethod.EMAIL]
},
tx
);
await orgDAL.createMembership({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
});
return newUser;
});
return createScimUser({
return buildScimUser({
userId: user.id,
firstName: user.firstName as string,
lastName: user.lastName as string,
email: user.email
email: user.email,
active: true
});
}
};
const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => {
const [membership] = await orgDAL
.findMembership({
userId,
orgId
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
if (!membership)
throw new ScimRequestError({
detail: "User not found",
status: 404
});
if (!membership.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
let active = true;
operations.forEach((operation) => {
if (operation.op.toLowerCase() === "replace") {
if (operation.path === "active" && operation.value === "False") {
// azure scim op format
active = false;
} else if (typeof operation.value === "object" && operation.value.active === false) {
// okta scim op format
active = false;
}
}
});
if (!active) {
await deleteOrgMembership({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL
});
}
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active
});
};
const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => {
const [membership] = await orgDAL
.findMembership({
userId,
orgId
})
.catch(() => {
throw new ScimRequestError({
detail: "User not found",
status: 404
});
});
if (!membership)
throw new ScimRequestError({
detail: "User not found",
status: 404
});
if (!membership.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
if (!active) {
// tx
await deleteOrgMembership({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL
});
}
return buildScimUser({
userId: membership.userId as string,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
email: membership.email,
active
});
};
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
// TODO: check expiry
const scimToken = await scimDAL.findById(token.scimTokenId);
if (!scimToken) throw new UnauthorizedError();
const { ttlDays, createdAt } = scimToken;
// ttl check
if (Number(ttlDays) > 0) {
const currentDate = new Date();
const scimTokenCreatedAt = new Date(createdAt);
const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400;
const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds);
if (currentDate > expirationDate)
throw new ScimRequestError({
detail: "The access token expired",
status: 401
});
}
return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
}
return {
};
return {
createScimToken,
getScimTokens,
listScimTokens,
deleteScimToken,
listUsers,
getUser,
createUser,
listScimUsers,
getScimUser,
createScimUser,
updateScimUser,
replaceScimUser,
fnValidateScimToken
};
};
+75 -29
View File
@@ -1,41 +1,87 @@
import { TOrgPermission } from "@app/lib/types";
import { TScimUser } from "@app/lib/scim";
export type TCreateScimTokenDTO = {
organizationId: string;
description: string;
ttl: number;
}
description: string;
ttlDays: number;
} & TOrgPermission;
// TODO: add org permissions
// & Omit<TOrgPermission, "orgId">;
export type TDeleteScimTokenDTO = {
scimTokenId: string;
} & Omit<TOrgPermission, "orgId">;
// SCIM server endpoint types
export type TListScimUsersDTO = {
offset: number;
limit: number;
filter?: string;
}
offset: number;
limit: number;
filter?: string;
orgId: string;
};
export type TListScimUsersRes = { // check naming here
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
totalResults: number;
Resources: TScimUser[];
itemsPerPage: number;
startIndex: number;
}
export type TListScimUsers = {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
totalResults: number;
Resources: TScimUser[];
itemsPerPage: number;
startIndex: number;
};
export type TGetScimUserDTO = {
userId: string;
orgId: string;
};
export type TCreateScimUserDTO = {
email: string;
firstName: string;
lastName: string;
orgId: string;
}
email: string;
firstName: string;
lastName: string;
orgId: string;
};
export type TCreateScimUserRes = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"]
}
export type TUpdateScimUserDTO = {
userId: string;
orgId: string;
operations: {
op: string;
path?: string;
value?:
| string
| {
active: boolean;
};
}[];
};
export type TReplaceScimUserDTO = {
userId: string;
active: boolean;
orgId: string;
};
export type TScimTokenJwtPayload = {
scimTokenId: string;
authTokenType: string;
};
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;
};
};
+17 -2
View File
@@ -61,12 +61,27 @@ export class BadRequestError extends Error {
export class ScimRequestError extends Error {
name: string;
schemas: string[];
detail: string;
status: number;
error: unknown;
constructor({ name, error, detail, status }: { message?: string; name?: string; error?: unknown, detail: string, status: number }) {
constructor({
name,
error,
detail,
status
}: {
message?: string;
name?: string;
error?: unknown;
detail: string;
status: number;
}) {
super(detail ?? "The request is invalid");
this.name = name || "ScimRequestError";
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
@@ -74,4 +89,4 @@ export class ScimRequestError extends Error {
this.detail = detail;
this.status = status;
}
}
}
-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 { TServiceTokens, TUsers } from "@app/db/schemas";
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types";
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
export type TAuthMode =
| {
+7 -1
View File
@@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability";
import fastifyPlugin from "fastify-plugin";
import { ZodError } from "zod";
import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError, ScimRequestError } from "@app/lib/errors";
import {
BadRequestError,
DatabaseError,
InternalServerError,
ScimRequestError,
UnauthorizedError
} from "@app/lib/errors";
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
server.setErrorHandler((error, req, res) => {
+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 { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal";
import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal";
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
@@ -32,8 +34,6 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
import { getConfig } from "@app/lib/config/env";
import { TQueueServiceFactory } from "@app/queue";
import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
@@ -191,13 +191,7 @@ export const registerRoutes = async (
trustedIpDAL,
permissionService
});
const scimService = scimServiceFactory({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService
});
const auditLogQueue = auditLogQueueServiceFactory({
auditLogDAL,
queueService,
@@ -220,6 +214,14 @@ export const registerRoutes = async (
samlConfigDAL,
licenseService
});
const scimService = scimServiceFactory({
licenseService,
scimDAL,
userDAL,
orgDAL,
permissionService,
smtpService
});
const telemetryService = telemetryServiceFactory();
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
@@ -93,7 +93,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
.trim()
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
.optional(),
authEnforced: z.boolean().optional()
authEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional()
}),
response: {
200: z.object({
+8 -1
View File
@@ -165,7 +165,14 @@ export const orgDALFactory = (db: TDbClient) => {
// eslint-disable-next-line
.where(buildFindFilter(filter))
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users));
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.select(
selectAllTableCols(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("scimEnabled").withSchema(TableName.Organization)
);
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
+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,
actorOrgId,
orgId,
data: { name, slug, authEnforced }
data: { name, slug, authEnforced, scimEnabled }
}: TUpdateOrgDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const plan = await licenseService.getPlan(orgId);
if (authEnforced !== undefined) {
if (!plan?.samlSSO)
throw new BadRequestError({
message:
"Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO."
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
}
if (authEnforced) {
if (scimEnabled !== undefined) {
if (!plan?.scim)
throw new BadRequestError({
message:
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
}
if (authEnforced || scimEnabled) {
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
if (!samlCfg)
throw new BadRequestError({
@@ -147,7 +163,8 @@ export const orgServiceFactory = ({
const org = await orgDAL.updateById(orgId, {
name,
slug: slug ? slugify(slug) : undefined,
authEnforced
authEnforced,
scimEnabled
});
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
return org;
+1 -1
View File
@@ -38,5 +38,5 @@ export type TFindAllWorkspacesDTO = {
};
export type TUpdateOrgDTO = {
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
} & TOrgPermission;
+2 -1
View File
@@ -25,7 +25,8 @@ export enum SmtpTemplates {
OrgInvite = "organizationInvitation.handlebars",
ResetPassword = "passwordReset.handlebars",
SecretLeakIncident = "secretLeakIncident.handlebars",
WorkspaceInvite = "workspaceInvitation.handlebars"
WorkspaceInvite = "workspaceInvitation.handlebars",
ScimUserProvisioned = "scimUserProvisioned.handlebars"
}
export enum SmtpHost {
@@ -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>