add createOrgUser endpoint

This commit is contained in:
miloschwartz 2025-04-23 13:26:38 -04:00
parent feb558cfa8
commit 6f59d0cd2d
No known key found for this signature in database
9 changed files with 302 additions and 81 deletions

View file

@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
listOrgs = "listOrgs",
listUserOrgs = "listUserOrgs",
createOrg = "createOrg",

View file

@ -26,7 +26,12 @@ import {
verifyUserAccess,
getUserOrgs,
verifyUserIsServerAdmin,
<<<<<<< Updated upstream
verifyIsLoggedInUser
=======
verifyIsLoggedInUser,
verifyClientAccess
>>>>>>> Stashed changes
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -46,6 +51,10 @@ unauthenticated.get("/", (_, res) => {
export const authenticated = Router();
authenticated.use(verifySessionUserMiddleware);
<<<<<<< Updated upstream
=======
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
>>>>>>> Stashed changes
authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
@ -448,7 +457,15 @@ authenticated.delete(
user.adminRemoveUser
);
authenticated.put(
"/org/:orgId/user",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get(
"/org/:orgId/users",
verifyOrgAccess,

View file

@ -5,11 +5,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
idp,
idpOidcConfig,
users
} from "@server/db/schemas";
import { idp, idpOidcConfig, users } from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
@ -17,6 +13,12 @@ import jmespath from "jmespath";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
createSession,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { response } from "@server/lib";
const paramsSchema = z
.object({
@ -213,31 +215,31 @@ export async function validateOidcCallback(
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"User not found in the IdP"
"User not provisioned in the system"
)
);
}
//
// const token = generateSessionToken();
// const sess = await createSession(token, existingUser.userId);
// const isSecure = req.protocol === "https";
// const cookie = serializeSessionCookie(
// token,
// isSecure,
// new Date(sess.expiresAt)
// );
//
// res.appendHeader("Set-Cookie", cookie);
//
// return response<ValidateOidcUrlCallbackResponse>(res, {
// data: {
// redirectUrl: postAuthRedirectUrl
// },
// success: true,
// error: false,
// message: "OIDC callback validated successfully",
// status: HttpCode.CREATED
// });
const token = generateSessionToken();
const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
}
} catch (error) {
logger.error(error);
@ -246,13 +248,3 @@ export async function validateOidcCallback(
);
}
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | undefined {
if (!orgMapping) {
return undefined;
}
return orgMapping.split("{{orgId}}").join(orgId);
}

View file

@ -0,0 +1,207 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db/schemas";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z
.object({
orgId: z.string().nonempty()
})
.strict();
const bodySchema = z
.object({
email: z.string().email().optional(),
username: z.string().nonempty(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),
idpId: z.number().optional(),
roleId: z.number()
})
.strict();
export type CreateOrgUserResponse = {};
registry.registerPath({
method: "put",
path: "/org/{orgId}/user",
description: "Create an organization user.",
tags: [OpenAPITags.User, OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createOrgUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleId } = parsedBody.data;
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId));
if (!role) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Role ID not found")
);
}
if (type === "internal") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Internal users are not supported yet"
)
);
} else if (type === "oidc") {
if (!idpId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IDP ID is required for OIDC users"
)
);
}
const [idpRes] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(eq(idp.idpId, idpId));
if (!idpRes) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found")
);
}
if (idpRes.idp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IDP ID is not of type OIDC"
)
);
}
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.username, username));
if (existingUser) {
const [existingOrgUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, existingUser.userId)
)
);
if (existingOrgUser) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User already exists in this organization"
)
);
}
await db
.insert(userOrgs)
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId
})
.returning();
} else {
const userId = generateId(15);
const [newUser] = await db
.insert(users)
.values({
userId: userId,
email,
username,
name,
type: "oidc",
idpId,
dateCreated: new Date().toISOString(),
emailVerified: true
})
.returning();
await db
.insert(userOrgs)
.values({
orgId,
userId: newUser.userId,
roleId: role.roleId
})
.returning();
}
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
);
}
return response<CreateOrgUserResponse>(res, {
data: {},
success: true,
error: false,
message: "Org user created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -9,3 +9,4 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";

View file

@ -153,22 +153,6 @@ export default function IdpTable({ idps }: Props) {
);
}
},
{
accessorKey: "orgCount",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Policies
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {

View file

@ -41,6 +41,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@app/components/ui/badge";
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
@ -218,20 +219,28 @@ export default function GeneralPage() {
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={true}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<Badge className="ml-2">Enterprise</Badge>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be automatically
created in the system upon first login using
this identity provider.
created in the system upon first login with
the ability to map users to roles and
organizations.
</span>
</form>
</Form>

View file

@ -35,6 +35,7 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
@ -87,7 +88,7 @@ export default function Page() {
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
autoProvision: true
autoProvision: false
}
});
@ -182,24 +183,30 @@ export default function Page() {
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={true}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<Badge className="ml-2">
Enterprise
</Badge>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login using this identity
provider.
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>

View file

@ -7,6 +7,7 @@ interface SwitchComponentProps {
label: string;
description?: string;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange: (checked: boolean) => void;
}
@ -14,6 +15,7 @@ export function SwitchInput({
id,
label,
description,
disabled,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
@ -24,6 +26,7 @@ export function SwitchInput({
id={id}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
<Label htmlFor={id}>{label}</Label>
</div>