Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: management-ui-dev
directory: dist/ui/
directory: dist_ui/
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: main
test:
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: management-ui-dev
directory: dist/ui/
directory: dist_ui/
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
test:
runs-on: ubuntu-latest
Expand All @@ -103,7 +103,7 @@ jobs:
node-version: 20.x
- uses: actions/checkout@v4
env:
HUSKY: "0"
HUSKY: "0"
- name: Set up Python 3.11 for testing
uses: actions/setup-python@v5
with:
Expand All @@ -126,7 +126,7 @@ jobs:
node-version: 20.x
- uses: actions/checkout@v4
env:
HUSKY: "0"
HUSKY: "0"
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
Expand Down Expand Up @@ -171,7 +171,7 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: management-ui-prod
directory: dist/ui/
directory: dist_ui/
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: main
health-check-prod:
Expand All @@ -189,6 +189,6 @@ jobs:
node-version: 20.x
- uses: actions/checkout@v4
env:
HUSKY: "0"
HUSKY: "0"
- name: Call the health check script
run: make prod_health_check
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ dist
.aws-sam/
build/
dist/
dist_ui/

*.pyc
__pycache__
__pycache__
192 changes: 185 additions & 7 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { genericConfig } from "../../common/config.js";
import {
EntraGroupError,
EntraInvitationError,
InternalServerError,
} from "../../common/errors/index.js";
import { getSecretValue } from "../plugins/auth.js";
import { ConfidentialClientApplication } from "@azure/msal-node";
import { getItemFromCache, insertItemIntoCache } from "./cache.js";
import {
EntraGroupActions,
EntraInvitationResponse,
} from "../../common/types/iam.js";

interface EntraInvitationResponse {
status: number;
data?: Record<string, string>;
error?: {
message: string;
code?: string;
};
function validateGroupId(groupId: string): boolean {
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
return groupIdPattern.test(groupId);
}

export async function getEntraIdToken(
clientId: string,
scopes: string[] = ["https://graph.microsoft.com/.default"],
Expand Down Expand Up @@ -76,6 +78,7 @@ export async function getEntraIdToken(

/**
* Adds a user to the tenant by sending an invitation to their email
* @param token - Entra ID token authorized to take this action.
* @param email - The email address of the user to invite
* @throws {InternalServerError} If the invitation fails
* @returns {Promise<boolean>} True if the invitation was successful
Expand Down Expand Up @@ -123,3 +126,178 @@ export async function addToTenant(token: string, email: string) {
});
}
}

/**
* Resolves an email address to an OID using Microsoft Graph API.
* @param token - Entra ID token authorized to perform this action.
* @param email - The email address to resolve.
* @throws {Error} If the resolution fails.
* @returns {Promise<string>} The OID of the user.
*/
export async function resolveEmailToOid(
token: string,
email: string,
): Promise<string> {
email = email.toLowerCase().replace(/\s/g, "");

const url = `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${email}'`;

const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new Error(errorData?.error?.message ?? response.statusText);
}

const data = (await response.json()) as {
value: { id: string }[];
};

if (!data.value || data.value.length === 0) {
throw new Error(`No user found with email: ${email}`);
}

return data.value[0].id;
}

/**
* Adds or removes a user from an Entra ID group.
* @param token - Entra ID token authorized to take this action.
* @param email - The email address of the user to add or remove.
* @param group - The group ID to take action on.
* @param action - Whether to add or remove the user from the group.
* @throws {EntraGroupError} If the group action fails.
* @returns {Promise<boolean>} True if the action was successful.
*/
export async function modifyGroup(
token: string,
email: string,
group: string,
action: EntraGroupActions,
): Promise<boolean> {
email = email.toLowerCase().replace(/\s/g, "");
if (!email.endsWith("@illinois.edu")) {
throw new EntraGroupError({
group,
message: "User's domain must be illinois.edu to be added to the group.",
});
}

try {
const oid = await resolveEmailToOid(token, email);
const methodMapper = {
[EntraGroupActions.ADD]: "POST",
[EntraGroupActions.REMOVE]: "DELETE",
};

const urlMapper = {
[EntraGroupActions.ADD]: `https://graph.microsoft.com/v1.0/groups/${group}/members/$ref`,
[EntraGroupActions.REMOVE]: `https://graph.microsoft.com/v1.0/groups/${group}/members/${oid}/$ref`,
};
const url = urlMapper[action];
const body = {
"@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${oid}`,
};

const response = await fetch(url, {
method: methodMapper[action],
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

if (!response.ok) {
const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new EntraGroupError({
message: errorData?.error?.message ?? response.statusText,
group,
});
}

return true;
} catch (error) {
if (error instanceof EntraGroupError) {
throw error;
}

throw new EntraGroupError({
message: error instanceof Error ? error.message : String(error),
group,
});
}
}

/**
* Lists all members of an Entra ID group.
* @param token - Entra ID token authorized to take this action.
* @param group - The group ID to fetch members for.
* @throws {EntraGroupError} If the group action fails.
* @returns {Promise<Array<{ name: string; email: string }>>} List of members with name and email.
*/
export async function listGroupMembers(
token: string,
group: string,
): Promise<Array<{ name: string; email: string }>> {
if (!validateGroupId(group)) {
throw new EntraGroupError({
message: "Invalid group ID format",
group,
});
}
try {
const url = `https://graph.microsoft.com/v1.0/groups/${group}/members`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = (await response.json()) as {
error?: { message?: string };
};
throw new EntraGroupError({
message: errorData?.error?.message ?? response.statusText,
group,
});
}

const data = (await response.json()) as {
value: Array<{
displayName?: string;
mail?: string;
}>;
};

// Map the response to the desired format
const members = data.value.map((member) => ({
name: member.displayName ?? "",
email: member.mail ?? "",
}));

return members;
} catch (error) {
if (error instanceof EntraGroupError) {
throw error;
}

throw new EntraGroupError({
message: error instanceof Error ? error.message : String(error),
group,
});
}
}
Loading
Loading