mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-27 22:25:58 +02:00
add default mapping policy and move auto provision
This commit is contained in:
parent
99188233db
commit
b4fda6a1f6
6 changed files with 475 additions and 257 deletions
|
@ -65,8 +65,7 @@ export enum ActionsEnum {
|
||||||
listResourceRules = "listResourceRules",
|
listResourceRules = "listResourceRules",
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
listOrgDomains = "listOrgDomains",
|
listOrgDomains = "listOrgDomains",
|
||||||
createNewt = "createNewt",
|
createNewt = "createNewt"
|
||||||
createIdp = "createIdp"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -425,7 +425,14 @@ export const supporterKey = sqliteTable("supporterKey", {
|
||||||
export const idp = sqliteTable("idp", {
|
export const idp = sqliteTable("idp", {
|
||||||
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
type: text("type").notNull()
|
type: text("type").notNull(),
|
||||||
|
defaultRoleMapping: text("defaultRoleMapping"),
|
||||||
|
defaultOrgMapping: text("defaultOrgMapping"),
|
||||||
|
autoProvision: integer("autoProvision", {
|
||||||
|
mode: "boolean"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Identity Provider OAuth Configuration
|
// Identity Provider OAuth Configuration
|
||||||
|
@ -440,11 +447,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||||
clientSecret: text("clientSecret").notNull(),
|
clientSecret: text("clientSecret").notNull(),
|
||||||
authUrl: text("authUrl").notNull(),
|
authUrl: text("authUrl").notNull(),
|
||||||
tokenUrl: text("tokenUrl").notNull(),
|
tokenUrl: text("tokenUrl").notNull(),
|
||||||
autoProvision: integer("autoProvision", {
|
|
||||||
mode: "boolean"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
identifierPath: text("identifierPath").notNull(),
|
identifierPath: text("identifierPath").notNull(),
|
||||||
emailPath: text("emailPath"),
|
emailPath: text("emailPath"),
|
||||||
namePath: text("namePath"),
|
namePath: text("namePath"),
|
||||||
|
|
|
@ -91,6 +91,7 @@ export async function createOidcIdp(
|
||||||
.insert(idp)
|
.insert(idp)
|
||||||
.values({
|
.values({
|
||||||
name,
|
name,
|
||||||
|
autoProvision,
|
||||||
type: "oidc"
|
type: "oidc"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
@ -103,7 +104,6 @@ export async function createOidcIdp(
|
||||||
clientSecret: encryptedSecret,
|
clientSecret: encryptedSecret,
|
||||||
authUrl,
|
authUrl,
|
||||||
tokenUrl,
|
tokenUrl,
|
||||||
autoProvision,
|
|
||||||
scopes,
|
scopes,
|
||||||
identifierPath,
|
identifierPath,
|
||||||
emailPath,
|
emailPath,
|
||||||
|
|
|
@ -20,16 +20,18 @@ const paramsSchema = z
|
||||||
|
|
||||||
const bodySchema = z
|
const bodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().nonempty(),
|
name: z.string().optional(),
|
||||||
clientId: z.string().nonempty(),
|
clientId: z.string().optional(),
|
||||||
clientSecret: z.string().nonempty(),
|
clientSecret: z.string().optional(),
|
||||||
authUrl: z.string().url(),
|
authUrl: z.string().optional(),
|
||||||
tokenUrl: z.string().url(),
|
tokenUrl: z.string().optional(),
|
||||||
identifierPath: z.string().nonempty(),
|
identifierPath: z.string().optional(),
|
||||||
emailPath: z.string().optional(),
|
emailPath: z.string().optional(),
|
||||||
namePath: z.string().optional(),
|
namePath: z.string().optional(),
|
||||||
scopes: z.string().optional(),
|
scopes: z.string().optional(),
|
||||||
autoProvision: z.boolean().optional()
|
autoProvision: z.boolean().optional(),
|
||||||
|
defaultRoleMapping: z.string().optional(),
|
||||||
|
defaultOrgMapping: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -92,7 +94,9 @@ export async function updateOidcIdp(
|
||||||
emailPath,
|
emailPath,
|
||||||
namePath,
|
namePath,
|
||||||
name,
|
name,
|
||||||
autoProvision
|
autoProvision,
|
||||||
|
defaultRoleMapping,
|
||||||
|
defaultOrgMapping
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
|
@ -115,33 +119,51 @@ export async function updateOidcIdp(
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret;
|
const key = config.getRawConfig().server.secret;
|
||||||
const encryptedSecret = encrypt(clientSecret, key);
|
const encryptedSecret = clientSecret
|
||||||
const encryptedClientId = encrypt(clientId, key);
|
? encrypt(clientSecret, key)
|
||||||
|
: undefined;
|
||||||
|
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Update IDP name
|
const idpData = {
|
||||||
await trx
|
name,
|
||||||
.update(idp)
|
autoProvision,
|
||||||
.set({
|
defaultRoleMapping,
|
||||||
name
|
defaultOrgMapping
|
||||||
})
|
};
|
||||||
.where(eq(idp.idpId, idpId));
|
|
||||||
|
|
||||||
// Update OIDC config
|
// only update if at least one key is not undefined
|
||||||
await trx
|
let keysToUpdate = Object.keys(idpData).filter(
|
||||||
.update(idpOidcConfig)
|
(key) => idpData[key as keyof typeof idpData] !== undefined
|
||||||
.set({
|
);
|
||||||
clientId: encryptedClientId,
|
|
||||||
clientSecret: encryptedSecret,
|
if (keysToUpdate.length > 0) {
|
||||||
authUrl,
|
await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
|
||||||
tokenUrl,
|
}
|
||||||
autoProvision,
|
|
||||||
scopes,
|
const configData = {
|
||||||
identifierPath,
|
clientId: encryptedClientId,
|
||||||
emailPath,
|
clientSecret: encryptedSecret,
|
||||||
namePath
|
authUrl,
|
||||||
})
|
tokenUrl,
|
||||||
.where(eq(idpOidcConfig.idpId, idpId));
|
scopes,
|
||||||
|
identifierPath,
|
||||||
|
emailPath,
|
||||||
|
namePath
|
||||||
|
};
|
||||||
|
|
||||||
|
keysToUpdate = Object.keys(configData).filter(
|
||||||
|
(key) =>
|
||||||
|
configData[key as keyof typeof configData] !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keysToUpdate.length > 0) {
|
||||||
|
// Update OIDC config
|
||||||
|
await trx
|
||||||
|
.update(idpOidcConfig)
|
||||||
|
.set(configData)
|
||||||
|
.where(eq(idpOidcConfig.idpId, idpId));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response<UpdateIdpResponse>(res, {
|
return response<UpdateIdpResponse>(res, {
|
||||||
|
|
|
@ -213,188 +213,229 @@ export async function validateOidcCallback(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const idpOrgs = await db
|
if (existingIdp.idp.autoProvision) {
|
||||||
.select()
|
const idpOrgs = await db
|
||||||
.from(idpOrg)
|
|
||||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
|
|
||||||
|
|
||||||
let userOrgInfo: { orgId: string; roleId: number }[] = [];
|
|
||||||
for (const idpOrg of idpOrgs) {
|
|
||||||
let roleId: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (idpOrg.orgMapping) {
|
|
||||||
const orgId = jmespath.search(claims, idpOrg.orgMapping);
|
|
||||||
if (!orgId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idpOrg.roleMapping) {
|
|
||||||
const roleName = jmespath.search(claims, idpOrg.roleMapping);
|
|
||||||
|
|
||||||
if (!roleName) {
|
|
||||||
logger.error("Role name not found in the ID token", {
|
|
||||||
roleName
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [roleRes] = await db
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roles.orgId, idpOrg.orgId),
|
|
||||||
eq(roles.name, roleName)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!roleRes) {
|
|
||||||
logger.error("Role not found", {
|
|
||||||
orgId: idpOrg.orgId,
|
|
||||||
roleName
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
roleId = roleRes.roleId;
|
|
||||||
|
|
||||||
userOrgInfo.push({
|
|
||||||
orgId: idpOrg.orgId,
|
|
||||||
roleId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("User org info", { userOrgInfo });
|
|
||||||
|
|
||||||
let existingUserId = existingUser?.userId;
|
|
||||||
|
|
||||||
// sync the user with the orgs and roles
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
let userId = existingUser?.userId;
|
|
||||||
|
|
||||||
// create user if not exists
|
|
||||||
if (!existingUser) {
|
|
||||||
userId = generateId(15);
|
|
||||||
|
|
||||||
await trx.insert(users).values({
|
|
||||||
userId,
|
|
||||||
username: userIdentifier,
|
|
||||||
email: email || null,
|
|
||||||
name: name || null,
|
|
||||||
type: UserType.OIDC,
|
|
||||||
idpId: existingIdp.idp.idpId,
|
|
||||||
emailVerified: true, // OIDC users are always verified
|
|
||||||
dateCreated: new Date().toISOString()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// set the name and email
|
|
||||||
await trx
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
username: userIdentifier,
|
|
||||||
email: email || null,
|
|
||||||
name: name || null
|
|
||||||
})
|
|
||||||
.where(eq(users.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
existingUserId = userId;
|
|
||||||
|
|
||||||
// get all current user orgs
|
|
||||||
const currentUserOrgs = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(idpOrg)
|
||||||
.where(eq(userOrgs.userId, userId));
|
.where(eq(idpOrg.idpId, existingIdp.idp.idpId));
|
||||||
|
|
||||||
// Delete orgs that are no longer valid
|
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
|
||||||
const orgsToDelete = currentUserOrgs.filter(
|
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
|
||||||
(currentOrg) =>
|
|
||||||
!userOrgInfo.some(
|
|
||||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (orgsToDelete.length > 0) {
|
let userOrgInfo: { orgId: string; roleId: number }[] = [];
|
||||||
await trx.delete(userOrgs).where(
|
for (const idpOrg of idpOrgs) {
|
||||||
and(
|
let roleId: number | undefined = undefined;
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
inArray(
|
|
||||||
userOrgs.orgId,
|
|
||||||
orgsToDelete.map((org) => org.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update roles for existing orgs where the role has changed
|
const orgMapping = idpOrg.orgMapping || defaultOrgMapping;
|
||||||
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
|
const hydratedOrgMapping = orgMapping
|
||||||
const newOrg = userOrgInfo.find(
|
?.split("{{orgId}}")
|
||||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
.join(idpOrg.orgId);
|
||||||
);
|
|
||||||
return newOrg && newOrg.roleId !== currentOrg.roleId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (orgsToUpdate.length > 0) {
|
if (hydratedOrgMapping) {
|
||||||
for (const org of orgsToUpdate) {
|
const orgId = jmespath.search(claims, hydratedOrgMapping);
|
||||||
const newRole = userOrgInfo.find(
|
if (!(orgId === true || orgId === idpOrg.orgId)) {
|
||||||
(newOrg) => newOrg.orgId === org.orgId
|
continue;
|
||||||
);
|
|
||||||
if (newRole) {
|
|
||||||
await trx
|
|
||||||
.update(userOrgs)
|
|
||||||
.set({ roleId: newRole.roleId })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, org.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleMapping = idpOrg.roleMapping || defaultRoleMapping;
|
||||||
|
if (roleMapping) {
|
||||||
|
const roleName = jmespath.search(claims, roleMapping);
|
||||||
|
|
||||||
|
if (!roleName) {
|
||||||
|
logger.error("Role name not found in the ID token", {
|
||||||
|
roleName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [roleRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roles.orgId, idpOrg.orgId),
|
||||||
|
eq(roles.name, roleName)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roleRes) {
|
||||||
|
logger.error("Role not found", {
|
||||||
|
orgId: idpOrg.orgId,
|
||||||
|
roleName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
roleId = roleRes.roleId;
|
||||||
|
|
||||||
|
userOrgInfo.push({
|
||||||
|
orgId: idpOrg.orgId,
|
||||||
|
roleId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new orgs that don't exist yet
|
logger.debug("User org info", { userOrgInfo });
|
||||||
const orgsToAdd = userOrgInfo.filter(
|
|
||||||
(newOrg) =>
|
let existingUserId = existingUser?.userId;
|
||||||
!currentUserOrgs.some(
|
|
||||||
(currentOrg) => currentOrg.orgId === newOrg.orgId
|
// sync the user with the orgs and roles
|
||||||
)
|
await db.transaction(async (trx) => {
|
||||||
|
let userId = existingUser?.userId;
|
||||||
|
|
||||||
|
// create user if not exists
|
||||||
|
if (!existingUser) {
|
||||||
|
userId = generateId(15);
|
||||||
|
|
||||||
|
await trx.insert(users).values({
|
||||||
|
userId,
|
||||||
|
username: userIdentifier,
|
||||||
|
email: email || null,
|
||||||
|
name: name || null,
|
||||||
|
type: UserType.OIDC,
|
||||||
|
idpId: existingIdp.idp.idpId,
|
||||||
|
emailVerified: true, // OIDC users are always verified
|
||||||
|
dateCreated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// set the name and email
|
||||||
|
await trx
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
username: userIdentifier,
|
||||||
|
email: email || null,
|
||||||
|
name: name || null
|
||||||
|
})
|
||||||
|
.where(eq(users.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
existingUserId = userId;
|
||||||
|
|
||||||
|
// get all current user orgs
|
||||||
|
const currentUserOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
// Delete orgs that are no longer valid
|
||||||
|
const orgsToDelete = currentUserOrgs.filter(
|
||||||
|
(currentOrg) =>
|
||||||
|
!userOrgInfo.some(
|
||||||
|
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsToDelete.length > 0) {
|
||||||
|
await trx.delete(userOrgs).where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
inArray(
|
||||||
|
userOrgs.orgId,
|
||||||
|
orgsToDelete.map((org) => org.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update roles for existing orgs where the role has changed
|
||||||
|
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
|
||||||
|
const newOrg = userOrgInfo.find(
|
||||||
|
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||||
|
);
|
||||||
|
return newOrg && newOrg.roleId !== currentOrg.roleId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orgsToUpdate.length > 0) {
|
||||||
|
for (const org of orgsToUpdate) {
|
||||||
|
const newRole = userOrgInfo.find(
|
||||||
|
(newOrg) => newOrg.orgId === org.orgId
|
||||||
|
);
|
||||||
|
if (newRole) {
|
||||||
|
await trx
|
||||||
|
.update(userOrgs)
|
||||||
|
.set({ roleId: newRole.roleId })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new orgs that don't exist yet
|
||||||
|
const orgsToAdd = userOrgInfo.filter(
|
||||||
|
(newOrg) =>
|
||||||
|
!currentUserOrgs.some(
|
||||||
|
(currentOrg) => currentOrg.orgId === newOrg.orgId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsToAdd.length > 0) {
|
||||||
|
await trx.insert(userOrgs).values(
|
||||||
|
orgsToAdd.map((org) => ({
|
||||||
|
userId,
|
||||||
|
orgId: org.orgId,
|
||||||
|
roleId: org.roleId,
|
||||||
|
dateCreated: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const sess = await createSession(token, existingUserId);
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(
|
||||||
|
token,
|
||||||
|
isSecure,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (orgsToAdd.length > 0) {
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
await trx.insert(userOrgs).values(
|
|
||||||
orgsToAdd.map((org) => ({
|
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||||
userId,
|
data: {
|
||||||
orgId: org.orgId,
|
redirectUrl: postAuthRedirectUrl
|
||||||
roleId: org.roleId,
|
},
|
||||||
dateCreated: new Date().toISOString()
|
success: true,
|
||||||
}))
|
error: false,
|
||||||
|
message: "OIDC callback validated successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!existingUser) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"User not found in the IdP"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
//
|
||||||
|
// const token = generateSessionToken();
|
||||||
const token = generateSessionToken();
|
// const sess = await createSession(token, existingUser.userId);
|
||||||
const sess = await createSession(token, existingUserId);
|
// 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);
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -32,7 +32,6 @@ import { z } from "zod";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
||||||
import PolicyTable, { PolicyRow } from "./PolicyTable";
|
import PolicyTable, { PolicyRow } from "./PolicyTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
import {
|
import {
|
||||||
|
@ -53,6 +52,17 @@ import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Textarea } from "@app/components/ui/textarea";
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { GetIdpResponse } from "@server/routers/idp";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
|
||||||
type Organization = {
|
type Organization = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
@ -65,7 +75,13 @@ const policyFormSchema = z.object({
|
||||||
orgMapping: z.string().optional()
|
orgMapping: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultMappingsSchema = z.object({
|
||||||
|
defaultRoleMapping: z.string().optional(),
|
||||||
|
defaultOrgMapping: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||||
|
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||||
|
|
||||||
export default function PoliciesPage() {
|
export default function PoliciesPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
@ -88,6 +104,35 @@ export default function PoliciesPage() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultMappingsForm = useForm<DefaultMappingsValues>({
|
||||||
|
resolver: zodResolver(defaultMappingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
defaultRoleMapping: "",
|
||||||
|
defaultOrgMapping: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadIdp = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<GetIdpResponse>>(
|
||||||
|
`/idp/${idpId}`
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
const data = res.data.data;
|
||||||
|
defaultMappingsForm.reset({
|
||||||
|
defaultRoleMapping: data.idp.defaultRoleMapping || "",
|
||||||
|
defaultOrgMapping: data.idp.defaultOrgMapping || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadPolicies = async () => {
|
const loadPolicies = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/idp/${idpId}/org`);
|
const res = await api.get(`/idp/${idpId}/org`);
|
||||||
|
@ -126,6 +171,7 @@ export default function PoliciesPage() {
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await loadPolicies();
|
await loadPolicies();
|
||||||
|
await loadIdp();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
@ -222,52 +268,160 @@ export default function PoliciesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/idp/${idpId}/oidc`, {
|
||||||
|
defaultRoleMapping: data.defaultRoleMapping,
|
||||||
|
defaultOrgMapping: data.defaultOrgMapping
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Default mappings updated successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert variant="neutral" className="mb-6">
|
<SettingsContainer>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<Alert variant="neutral" className="mb-6">
|
||||||
<AlertTitle className="font-semibold">
|
<InfoIcon className="h-4 w-4" />
|
||||||
About Organization Policies
|
<AlertTitle className="font-semibold">
|
||||||
</AlertTitle>
|
About Organization Policies
|
||||||
<AlertDescription>
|
</AlertTitle>
|
||||||
Organization policies are used to control access to
|
<AlertDescription>
|
||||||
organizations based on the user's ID token. You can specify
|
Organization policies are used to control access to
|
||||||
JMESPath expressions to extract role and organization
|
organizations based on the user's ID token. You can
|
||||||
information from the ID token. For more information, see{" "}
|
specify JMESPath expressions to extract role and
|
||||||
<Link
|
organization information from the ID token. For more
|
||||||
href=""
|
information, see{" "}
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noopener noreferrer"
|
href=""
|
||||||
className="text-primary hover:underline"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
the documentation
|
className="text-primary hover:underline"
|
||||||
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
>
|
||||||
</Link>
|
the documentation
|
||||||
</AlertDescription>
|
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||||
</Alert>
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<PolicyTable
|
<SettingsSection>
|
||||||
policies={policies}
|
<SettingsSectionHeader>
|
||||||
onDelete={onDeletePolicy}
|
<SettingsSectionTitle>
|
||||||
onAdd={() => {
|
Default Mappings (Optional)
|
||||||
loadOrganizations();
|
</SettingsSectionTitle>
|
||||||
setEditingPolicy(null);
|
<SettingsSectionDescription>
|
||||||
setShowAddDialog(true);
|
The default mappings are used when when there is not
|
||||||
}}
|
an organization policy defined for an organization.
|
||||||
onEdit={(policy) => {
|
You can specify the default role and organization
|
||||||
setEditingPolicy(policy);
|
mappings to fall back to here.
|
||||||
form.reset({
|
</SettingsSectionDescription>
|
||||||
orgId: policy.orgId,
|
</SettingsSectionHeader>
|
||||||
roleMapping: policy.roleMapping || "",
|
<SettingsSectionBody>
|
||||||
orgMapping: policy.orgMapping || ""
|
<Form {...defaultMappingsForm}>
|
||||||
});
|
<form
|
||||||
setShowAddDialog(true);
|
onSubmit={defaultMappingsForm.handleSubmit(
|
||||||
}}
|
onUpdateDefaultMappings
|
||||||
/>
|
)}
|
||||||
|
id="policy-default-mappings-form"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={defaultMappingsForm.control}
|
||||||
|
name="defaultRoleMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Default Role Mapping
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract role
|
||||||
|
information from the ID
|
||||||
|
token. The result of this
|
||||||
|
expression must return the
|
||||||
|
role name as defined in the
|
||||||
|
organization as a string.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={defaultMappingsForm.control}
|
||||||
|
name="defaultOrgMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Default Organization Mapping
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract
|
||||||
|
organization information
|
||||||
|
from the ID token. This
|
||||||
|
expression must return true
|
||||||
|
for the user to be allowed
|
||||||
|
to access the organization.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="policy-default-mappings-form"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Save Default Mappings
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<PolicyTable
|
||||||
|
policies={policies}
|
||||||
|
onDelete={onDeletePolicy}
|
||||||
|
onAdd={() => {
|
||||||
|
loadOrganizations();
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
onEdit={(policy) => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
form.reset({
|
||||||
|
orgId: policy.orgId,
|
||||||
|
roleMapping: policy.roleMapping || "",
|
||||||
|
orgMapping: policy.orgMapping || ""
|
||||||
|
});
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
<Credenza
|
<Credenza
|
||||||
open={showAddDialog}
|
open={showAddDialog}
|
||||||
|
@ -392,13 +546,14 @@ export default function PoliciesPage() {
|
||||||
Role Mapping Path (Optional)
|
Role Mapping Path (Optional)
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
JMESPath to extract role
|
JMESPath to extract role
|
||||||
information from the ID token.
|
information from the ID token.
|
||||||
The result of this expression
|
The result of this expression
|
||||||
must return the role name as a
|
must return the role name as
|
||||||
|
defined in the organization as a
|
||||||
string.
|
string.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
@ -416,15 +571,14 @@ export default function PoliciesPage() {
|
||||||
(Optional)
|
(Optional)
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
JMESPath to extract organization
|
JMESPath to extract organization
|
||||||
information from the ID token.
|
information from the ID token.
|
||||||
This expression must return a
|
This expression must return true
|
||||||
truthy value for the user to be
|
for the user to be allowed to
|
||||||
allowed to access the
|
access the organization.
|
||||||
organization.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue