Compare commits

..

5 Commits

Author SHA1 Message Date
853f3c40bc Adjustments to migration file 2024-07-16 22:20:56 +07:00
a1d00f2c41 Add SCIM user activation/deactivation 2024-07-16 20:19:27 +07:00
c90ecd336c Merge pull request #2122 from Infisical/fix-scim-groups
Fix SCIM PATCH /Groups Fn
2024-07-15 00:57:46 +07:00
58e86382fe Merge pull request #2119 from Infisical/update-used-count-on-prem
Update dynamic seat count on prem
2024-07-14 20:13:42 +07:00
2080c4419e Update dynamic seat count on prem 2024-07-14 14:25:32 +07:00
19 changed files with 186 additions and 57 deletions

View File

@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.OrgMembership)) {
const doesUserIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "userId");
const doesOrgIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "orgId");
await knex.schema.alterTable(TableName.OrgMembership, (t) => {
t.boolean("isActive").notNullable().defaultTo(true);
if (doesUserIdExist && doesOrgIdExist) t.index(["userId", "orgId"]);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.OrgMembership)) {
const doesUserIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "userId");
const doesOrgIdExist = await knex.schema.hasColumn(TableName.OrgMembership, "orgId");
await knex.schema.alterTable(TableName.OrgMembership, (t) => {
t.dropColumn("isActive");
if (doesUserIdExist && doesOrgIdExist) t.dropIndex(["userId", "orgId"]);
});
}
}

View File

@ -17,7 +17,8 @@ export const OrgMembershipsSchema = z.object({
userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid(),
roleId: z.string().uuid().nullable().optional(),
projectFavorites: z.string().array().nullable().optional()
projectFavorites: z.string().array().nullable().optional(),
isActive: z.boolean()
});
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;

View File

@ -29,7 +29,8 @@ export async function seed(knex: Knex): Promise<void> {
role: OrgMembershipRole.Admin,
orgId: org.id,
status: OrgMembershipStatus.Accepted,
userId: user.id
userId: user.id,
isActive: true
}
]);
}

View File

@ -186,7 +186,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
})
),
displayName: z.string().trim(),
active: z.boolean()
active: z.boolean(),
groups: z.array(
z.object({
value: z.string().trim(),
display: z.string().trim()
})
)
})
}
},
@ -572,7 +578,13 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
})
),
displayName: z.string().trim(),
active: z.boolean()
active: z.boolean(),
groups: z.array(
z.object({
value: z.string().trim(),
display: z.string().trim()
})
)
})
}
},

View File

@ -162,11 +162,26 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
}
};
const findUserGroupMembershipsInOrg = async (userId: string, orgId: string) => {
try {
const docs = await db
.replicaNode()(TableName.UserGroupMembership)
.join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`)
.where(`${TableName.UserGroupMembership}.userId`, userId)
.where(`${TableName.Groups}.orgId`, orgId);
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "findTest" });
}
};
return {
...userGroupMembershipOrm,
filterProjectsByUserMembership,
findUserGroupMembershipsInProject,
findGroupMembersNotInProject,
deletePendingUserGroupMembershipsByUserIds
deletePendingUserGroupMembershipsByUserIds,
findUserGroupMembershipsInOrg
};
};

View File

@ -449,7 +449,8 @@ export const ldapConfigServiceFactory = ({
userId: userAlias.userId,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
@ -534,7 +535,8 @@ export const ldapConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -218,6 +218,8 @@ export const licenseServiceFactory = ({
} else if (instanceType === InstanceType.EnterpriseOnPrem) {
const usedSeats = await licenseDAL.countOfOrgMembers(null, tx);
const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null, tx);
onPremFeatures.membersUsed = usedSeats;
onPremFeatures.identitiesUsed = usedIdentitySeats;
await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, {
usedSeats,
usedIdentitySeats

View File

@ -30,9 +30,9 @@ export type TFeatureSet = {
workspacesUsed: 0;
dynamicSecret: false;
memberLimit: null;
membersUsed: 0;
membersUsed: number;
identityLimit: null;
identitiesUsed: 0;
identitiesUsed: number;
environmentLimit: null;
environmentsUsed: 0;
secretVersioning: true;

View File

@ -193,7 +193,8 @@ export const oidcConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -266,7 +267,8 @@ export const oidcConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -370,7 +370,8 @@ export const samlConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -457,7 +458,8 @@ export const samlConfigServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);

View File

@ -32,12 +32,19 @@ export const parseScimFilter = (filterToParse: string | undefined) => {
return { [attributeName]: parsedValue.replace(/"/g, "") };
};
export function extractScimValueFromPath(path: string): string | null {
const regex = /members\[value eq "([^"]+)"\]/;
const match = path.match(regex);
return match ? match[1] : null;
}
export const buildScimUser = ({
orgMembershipId,
username,
email,
firstName,
lastName,
groups = [],
active
}: {
orgMembershipId: string;
@ -45,6 +52,10 @@ export const buildScimUser = ({
email?: string | null;
firstName: string;
lastName: string;
groups?: {
value: string;
display: string;
}[];
active: boolean;
}): TScimUser => {
const scimUser = {
@ -67,7 +78,7 @@ export const buildScimUser = ({
]
: [],
active,
groups: [],
groups,
meta: {
resourceType: "User",
location: null

View File

@ -30,7 +30,14 @@ import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns";
import {
buildScimGroup,
buildScimGroupList,
buildScimUser,
buildScimUserList,
extractScimValueFromPath,
parseScimFilter
} from "./scim-fns";
import {
TCreateScimGroupDTO,
TCreateScimTokenDTO,
@ -61,7 +68,7 @@ type TScimServiceFactoryDep = {
TOrgDALFactory,
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById"
>;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById" | "findById">;
projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete" | "findProjectMembershipsByUserId">;
groupDAL: Pick<
@ -71,7 +78,12 @@ type TScimServiceFactoryDep = {
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete"
| "find"
| "transaction"
| "insertMany"
| "filterProjectsByUserMembership"
| "delete"
| "findUserGroupMembershipsInOrg"
>;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
@ -197,14 +209,14 @@ export const scimServiceFactory = ({
findOpts
);
const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) =>
const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email, isActive }) =>
buildScimUser({
orgMembershipId: id ?? "",
username: externalId ?? username,
firstName: firstName ?? "",
lastName: lastName ?? "",
email,
active: true
active: isActive
})
);
@ -240,13 +252,19 @@ export const scimServiceFactory = ({
status: 403
});
const groupMembershipsInOrg = await userGroupMembershipDAL.findUserGroupMembershipsInOrg(membership.userId, orgId);
return buildScimUser({
orgMembershipId: membership.id,
username: membership.externalId ?? membership.username,
email: membership.email ?? "",
firstName: membership.firstName as string,
lastName: membership.lastName as string,
active: true
active: membership.isActive,
groups: groupMembershipsInOrg.map((group) => ({
value: group.groupId,
display: group.name
}))
});
};
@ -296,7 +314,8 @@ export const scimServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -364,7 +383,8 @@ export const scimServiceFactory = ({
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
tx
);
@ -401,7 +421,7 @@ export const scimServiceFactory = ({
firstName: createdUser.firstName as string,
lastName: createdUser.lastName as string,
email: createdUser.email ?? "",
active: true
active: createdOrgMembership.isActive
});
};
@ -445,14 +465,8 @@ export const scimServiceFactory = ({
});
if (!active) {
await deleteOrgMembershipFn({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL,
projectMembershipDAL,
projectKeyDAL,
userAliasDAL,
licenseService
await orgMembershipDAL.updateById(membership.id, {
isActive: false
});
}
@ -491,17 +505,11 @@ export const scimServiceFactory = ({
status: 403
});
if (!active) {
await deleteOrgMembershipFn({
orgMembershipId: membership.id,
orgId: membership.orgId,
orgDAL,
projectMembershipDAL,
projectKeyDAL,
userAliasDAL,
licenseService
});
}
await orgMembershipDAL.updateById(membership.id, {
isActive: active
});
const groupMembershipsInOrg = await userGroupMembershipDAL.findUserGroupMembershipsInOrg(membership.userId, orgId);
return buildScimUser({
orgMembershipId: membership.id,
@ -509,7 +517,11 @@ export const scimServiceFactory = ({
email: membership.email,
firstName: membership.firstName as string,
lastName: membership.lastName as string,
active
active,
groups: groupMembershipsInOrg.map((group) => ({
value: group.groupId,
display: group.name
}))
});
};
@ -881,7 +893,18 @@ export const scimServiceFactory = ({
break;
}
case "remove": {
// TODO
const orgMembershipId = extractScimValueFromPath(operation.path);
if (!orgMembershipId) throw new ScimRequestError({ detail: "Invalid path value", status: 400 });
const orgMembership = await orgMembershipDAL.findById(orgMembershipId);
if (!orgMembership) throw new ScimRequestError({ detail: "Org Membership Not Found", status: 400 });
await removeUsersFromGroupByUserIds({
group,
userIds: [orgMembership.userId as string],
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL
});
break;
}
default: {

View File

@ -158,7 +158,10 @@ export type TScimUser = {
type: string;
}[];
active: boolean;
groups: string[];
groups: {
value: string;
display: string;
}[];
meta: {
resourceType: string;
location: null;

View File

@ -331,7 +331,7 @@ export const registerRoutes = async (
permissionService,
secretApprovalPolicyDAL
});
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL });
const samlService = samlConfigServiceFactory({
permissionService,

View File

@ -4,7 +4,8 @@ import bcrypt from "bcrypt";
import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { AuthModeJwtTokenPayload } from "../auth/auth-type";
import { TUserDALFactory } from "../user/user-dal";
@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo
type TAuthTokenServiceFactoryDep = {
tokenDAL: TTokenDALFactory;
userDAL: Pick<TUserDALFactory, "findById" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "findOne">;
};
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
@ -67,7 +69,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
}
};
export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFactoryDep) => {
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig();
@ -154,6 +156,16 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact
const user = await userDAL.findById(session.userId);
if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" });
if (token.organizationId) {
const orgMembership = await orgMembershipDAL.findOne({
userId: user.id,
orgId: token.organizationId
});
if (!orgMembership) throw new ForbiddenRequestError({ message: "User not member of organization" });
if (!orgMembership.isActive) throw new ForbiddenRequestError({ message: "User not active in organization" });
}
return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId };
};

View File

@ -74,6 +74,7 @@ export const orgDALFactory = (db: TDbClient) => {
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("isActive").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),

View File

@ -207,7 +207,8 @@ export const orgServiceFactory = ({
orgId,
userId: user.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
};
await orgDAL.createMembership(createMembershipData, tx);
@ -311,7 +312,8 @@ export const orgServiceFactory = ({
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
@ -460,7 +462,8 @@ export const orgServiceFactory = ({
inviteEmail: inviteeEmail,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
status: OrgMembershipStatus.Invited,
isActive: true
},
tx
);
@ -491,7 +494,8 @@ export const orgServiceFactory = ({
orgId,
userId: user.id,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
status: OrgMembershipStatus.Invited,
isActive: true
},
tx
);

View File

@ -60,6 +60,7 @@ export type OrgUser = {
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
roleId: string;
isActive: boolean;
};
export type TProjectMembership = {

View File

@ -171,14 +171,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading &&
filterdUser?.map(
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status }) => {
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{username}</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
@ -186,7 +186,18 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
>
{(isAllowed) => (
<>
{status === "accepted" && (
{!isActive && (
<Button
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => {}}
>
Suspended
</Button>
)}
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
@ -207,7 +218,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
))}
</Select>
)}
{(status === "invited" || status === "verified") &&
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Button