mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-13 05:40:38 +01:00
add createOrgUser endpoint
This commit is contained in:
parent
feb558cfa8
commit
6f59d0cd2d
9 changed files with 302 additions and 81 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
207
server/routers/user/createOrgUser.ts
Normal file
207
server/routers/user/createOrgUser.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -9,3 +9,4 @@ export * from "./adminListUsers";
|
|||
export * from "./adminRemoveUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<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);
|
||||
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>
|
||||
|
|
|
@ -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,12 +183,14 @@ export default function Page() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<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",
|
||||
|
@ -195,11 +198,15 @@ export default function Page() {
|
|||
);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue