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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ ENCRYPTION_KEY=""
# you generate it with `openssl rand -hex 20`
BETTER_AUTH_SECRET=""

# Optional. Omit all three to use the seeded in-memory Azure directory.
# Set all three to connect to Microsoft Graph.
AZURE_TENANT_ID=""
AZURE_CLIENT_ID=""
AZURE_CLIENT_SECRET=""
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
test:
name: Typecheck and Lint
name: Typecheck, test, and lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand All @@ -17,5 +17,7 @@ jobs:
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Run tests
run: bun run test
- name: Run Biome
run: bun run biome ci .
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ Requirements:

- Bun installed
- A Postgres database
- An Azure App Registration

> [!NOTE]
> You can skip Azure by making `AZURE_*` env vars optional in `./src/env.ts`
> and by removing all Azure related auth in `./src/azure/`
> Azure credentials are optional. When `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and
> `AZURE_CLIENT_SECRET` are all omitted, the Azure tRPC routes use a seeded in-memory directory.
> Its changes last until the backend restarts. Set all three variables to connect to Microsoft Graph.

1. Install packages
```sh
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"build:npm": "NODE_ENV=production tsup --config tsup.npm.config.ts",
"email": "email dev --port 3012 --dir src/emails/templates",
"start": "bun run ./dist/server.js",
"test": "vitest run",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
Expand Down
42 changes: 21 additions & 21 deletions src/azure/client.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import { ClientSecretCredential } from "@azure/identity"
import { Client, type GraphError } from "@microsoft/microsoft-graph-client"
import { Client } from "@microsoft/microsoft-graph-client"
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"
import type { Organization } from "@microsoft/microsoft-graph-types"
import { env } from "@/env"
import { logger } from "@/logger"
import { azureCredentials } from "./config"

const credentials = new ClientSecretCredential(env.AZURE_TENANT_ID, env.AZURE_CLIENT_ID, env.AZURE_CLIENT_SECRET)
let client: Client | undefined

const authProvider = new TokenCredentialAuthenticationProvider(credentials, {
// the scopes are configured directly on the App Registration
// this is required by the flow to obtain those scopes
// https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=typescript#using-a-client-certificate-5
scopes: ["https://graph.microsoft.com/.default"],
})
export function getAzureClient(): Client {
if (client) return client
if (!azureCredentials) throw new Error("Azure Graph cannot be used without credentials")

export const client = Client.initWithMiddleware({ authProvider })

// test request -- void because we do not want to wait
void client
.api(`/organization/${env.AZURE_TENANT_ID}`)
.get()
.then((r: Organization) => logger.info({ orgName: r.displayName }, "[Azure Graph API] Client connected successfully"))
.catch((e: GraphError) => {
if (e.code === "AuthenticationRequiredError")
logger.error({ error: e.message }, "[Azure Graph API] Authentication failed, check credentials")
else logger.error({ error: e }, "[Azure Graph API] Error on TEST request")
const credentials = new ClientSecretCredential(
azureCredentials.tenantId,
azureCredentials.clientId,
azureCredentials.clientSecret
)
const authProvider = new TokenCredentialAuthenticationProvider(credentials, {
// the scopes are configured directly on the App Registration
// this is required by the flow to obtain those scopes
// https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=typescript#using-a-client-certificate-5
scopes: ["https://graph.microsoft.com/.default"],
})

client = Client.initWithMiddleware({ authProvider })
logger.info("[Azure Graph API] Client initialized")
return client
}
25 changes: 25 additions & 0 deletions src/azure/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { env } from "@/env"

export type AzureCredentials = {
tenantId: string
clientId: string
clientSecret: string
}

function resolveAzureCredentials(): AzureCredentials | null {
const values = {
tenantId: env.AZURE_TENANT_ID,
clientId: env.AZURE_CLIENT_ID,
clientSecret: env.AZURE_CLIENT_SECRET,
}
const configuredCount = Object.values(values).filter(Boolean).length

if (configuredCount === 0) return null
if (configuredCount !== Object.keys(values).length) {
throw new Error("Set AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET together, or omit all three")
}

return values as AzureCredentials
}

export const azureCredentials = resolveAzureCredentials()
21 changes: 21 additions & 0 deletions src/azure/directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { logger } from "@/logger"
import { azureCredentials } from "./config"
import { addGroupMember, getAllGroups, removeGroupMember } from "./functions/groups"
import { createMember, getMembers, setMemberNumber } from "./functions/members"
import { createMockAzureDirectory } from "./mock-directory"
import type { AzureDirectory } from "./types"

const graphAzureDirectory: AzureDirectory = {
getMembers,
setMemberNumber,
createMember,
getAllGroups,
addGroupMember,
removeGroupMember,
}

export const azureDirectory: AzureDirectory = azureCredentials ? graphAzureDirectory : createMockAzureDirectory()

if (!azureCredentials) {
logger.warn("Azure credentials are not set, using the seeded in-memory directory")
}
3 changes: 2 additions & 1 deletion src/azure/functions/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import MailComposer from "nodemailer/lib/mail-composer/index.js"
import type { JSX } from "react"
import { env } from "@/env"
import { logger } from "@/logger"
import { client } from "../client"
import { getAzureClient } from "../client"

export async function sendEmail(to: string, subject: string, component: JSX.Element) {
const html = await render(component)
Expand All @@ -27,6 +27,7 @@ export async function sendEmail(to: string, subject: string, component: JSX.Elem
const base64Encoded = mimeMessage.toString("base64")

try {
const client = getAzureClient()
await client.api(`/users/${sender}/sendMail`).header("Content-Type", "text/plain").post(base64Encoded)
logger.info({ subject, to }, "[Azure Graph API] Email sent")

Expand Down
11 changes: 7 additions & 4 deletions src/azure/functions/groups.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Group as TGroup, User as TUser } from "@microsoft/microsoft-graph-types"
import { logger } from "@/logger"
import { withRetry } from "@/utils/wait"
import { client } from "../client"
import { getAzureClient } from "../client"
import type { ParsedGroup } from "../types"

export type Group = Pick<Required<TGroup>, "id" | "displayName" | "mailNickname" | "mailEnabled"> & {
Expand All @@ -10,6 +10,7 @@ export type Group = Pick<Required<TGroup>, "id" | "displayName" | "mailNickname"

export async function getAllGroups(): Promise<ParsedGroup[]> {
try {
const client = getAzureClient()
const res: Group[] = await client
.api("/groups?$select=id,displayName,mailNickname,mailEnabled&$expand=members($select=id,displayName)")
.get()
Expand All @@ -28,12 +29,13 @@ export async function getAllGroups(): Promise<ParsedGroup[]> {

export async function addGroupMember(groupId: string, userId: string): Promise<boolean> {
try {
const res = withRetry(() =>
const client = getAzureClient()
await withRetry(() =>
client.api(`/groups/${groupId}/members/$ref`).post({
"@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${userId}`,
})
)
logger.debug({ res, userId, groupId }, "[MS Graph API] OK addGroupMember call")
logger.debug({ userId, groupId }, "[MS Graph API] OK addGroupMember call")
return true
} catch (error) {
logger.error({ error, userId, groupId }, "[MS Graph API] Error in addGroupMember call")
Expand All @@ -42,7 +44,8 @@ export async function addGroupMember(groupId: string, userId: string): Promise<b
}
export async function removeGroupMember(groupId: string, userId: string): Promise<boolean> {
try {
withRetry(() => client.api(`/groups/${groupId}/members/${userId}/$ref`).delete())
const client = getAzureClient()
await withRetry(() => client.api(`/groups/${groupId}/members/${userId}/$ref`).delete())
logger.debug({ userId, groupId }, "[MS Graph API] OK removeGroupMember call")
return true
} catch (error) {
Expand Down
19 changes: 8 additions & 11 deletions src/azure/functions/members.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { logger } from "@/logger"
import { generatePassword } from "@/utils/password"
import { withRetry } from "@/utils/wait"
import { client } from "../client"
import type { ParsedUser, User } from "../types"
import { getAzureClient } from "../client"
import type { CreatedMember, CreateMemberInput, ParsedUser, User } from "../types"

const GruppoSociID = "1c68dbb8-4ac3-4569-a886-283b5a825cbd"
const Licenses = {
Expand All @@ -15,6 +15,7 @@ const FlippedLicenses = Object.fromEntries(Object.entries(Licenses).map(([key, v

export async function getMembers(): Promise<ParsedUser[]> {
try {
const client = getAzureClient()
const allPolinetworkUsers: User[] = await client
.api(`/users`)
.header("ConsistencyLevel", "eventual")
Expand Down Expand Up @@ -47,6 +48,7 @@ export async function getMembers(): Promise<ParsedUser[]> {

export async function setMemberNumber(userId: string, assocNumber: number) {
try {
const client = getAzureClient()
await client.api(`/users/${userId}`).patch({
employeeId: assocNumber.toString(),
})
Expand All @@ -56,15 +58,8 @@ export async function setMemberNumber(userId: string, assocNumber: number) {
}
}

export async function createMember({
firstName,
lastName,
assocNumber,
}: {
firstName: string
lastName: string
assocNumber: number
}) {
export async function createMember({ firstName, lastName, assocNumber }: CreateMemberInput): Promise<CreatedMember> {
const client = getAzureClient()
// TODO: separate steps and add better error handling, maybe with neverthrow
const password = generatePassword()
const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase()
Expand Down Expand Up @@ -112,6 +107,7 @@ export async function createMember({
}

export async function changePassword(userId: string) {
const client = getAzureClient()
const password = generatePassword()

await withRetry(() =>
Expand All @@ -130,6 +126,7 @@ export async function manageLicenses(
addLicenses: (keyof typeof Licenses)[],
removeLicenses: (keyof typeof Licenses)[]
) {
const client = getAzureClient()
await withRetry(() =>
client.api(`/users/${userId}/assignLicense`).post({
addLicenses: addLicenses.map((l) => ({
Expand Down
Loading