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"; import HttpCode from "@server/types/HttpCode";
export enum ActionsEnum { export enum ActionsEnum {
createOrgUser = "createOrgUser",
listOrgs = "listOrgs", listOrgs = "listOrgs",
listUserOrgs = "listUserOrgs", listUserOrgs = "listUserOrgs",
createOrg = "createOrg", createOrg = "createOrg",

View file

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

View file

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

View file

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

View file

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

View file

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