Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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__
183 changes: 175 additions & 8 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
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;
};
}
export async function getEntraIdToken(
clientId: string,
scopes: string[] = ["https://graph.microsoft.com/.default"],
Expand Down Expand Up @@ -76,6 +73,7 @@

/**
* 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 +121,172 @@
});
}
}

/**
* 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 }>> {
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