mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-13 23:45:00 +02: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";
|
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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
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 "./adminRemoveUser";
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
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",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue