diff --git a/README.md b/README.md index eefbad1..af3dec2 100644 --- a/README.md +++ b/README.md @@ -173,13 +173,41 @@ using var response = await http.SendAsync(request); response.EnsureSuccessStatusCode(); ``` +## Multitenant napojení klientů (CSBOT MS365 Connector) + +Služba je stavěná pro jednu multitenant Entra aplikaci CSBOT, přes kterou se připojují +Microsoft 365 tenanty jednotlivých klientů. Podrobné zadání je v Notion stránce +"MS365 práva konektoru". + +- `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET` patří aplikaci CSBOT a jsou pro všechny klienty stejné. +- `MS365_TENANT_ID` je tenant konkrétního klienta. Token se získává proti + `https://login.microsoftonline.com/{tenant klienta}/oauth2/v2.0/token`. +- Při volání za různé klienty se tenant id posílá v hlavičce `X-MS365-Tenant-Id` + (viz "Credentials v request hlavičkách"), client id a secret zůstávají stejné. +- Klient nevytváří vlastní App Registration ani nepředává secret. Jeho administrátor jen + schválí admin consent. URL pro consent vrací `GET /admin-consent/url?redirect_uri=...&state=...` + (bere tenant id a client id z hlaviček nebo z env). Callback po consentu (parametry `tenant` + a `admin_consent=True`) zpracovává backend CSBOT, ne tato služba. +- Služba je bezstavová a neověřuje, zda mailbox, plan id nebo bucket id patří danému + zákazníkovi. Tenant isolation (customer id -> tenant id, mailbox, plan id, bucket id) + musí držet volající backend, hodnoty nesmí přicházet přímo od LLM. + +Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar/view`, +`POST /users/{mailbox}/calendar/events` + `DELETE`, `GET /planner/plans/{plan_id}/tasks`, +`POST /planner/tasks` + `DELETE`. + ## Implementované služby - Users: výpis a načtení uživatelů. - Mail: výpis zpráv a odesílání e-mailů včetně příloh. -- Calendar: výpis a vytváření událostí včetně Teams online meetingů. +- Calendar: výpis událostí, calendarView v zadaném rozsahu, volné termíny (getSchedule), + načtení, vytvoření, úprava a smazání události včetně Teams online meetingů. +- Planner: výpis plánů skupiny, bucketů a tasků plánu, načtení, vytvoření, úprava + a smazání tasku. Úprava a smazání používají ETag (`If-Match`). Když hlavička chybí, + služba si aktuální ETag načte sama. - OneDrive: výpis souborů v rootu a upload malých souborů. - Groups a Teams: výpis skupin a výpis kanálů týmu. +- Admin consent: sestavení consent URL pro administrátora klientského tenantu. ## API @@ -191,14 +219,32 @@ GET /users?top=25&search=jane GET /users/{user_id} GET /users/{user_id}/mail/messages?folder=Inbox&top=25 POST /users/{user_id}/mail/send +GET /admin-consent/url?redirect_uri=...&state=... GET /users/{user_id}/calendar/events?top=25 POST /users/{user_id}/calendar/events +GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague +POST /users/{user_id}/calendar/schedule +GET /users/{user_id}/calendar/events/{event_id} +PATCH /users/{user_id}/calendar/events/{event_id} +DELETE /users/{user_id}/calendar/events/{event_id} GET /users/{user_id}/drive/root/children?top=25 PUT /users/{user_id}/drive/root/{path} GET /groups?top=25 +GET /groups/{group_id}/planner/plans GET /teams/{team_id}/channels +GET /planner/plans/{plan_id}/buckets +GET /planner/plans/{plan_id}/tasks +POST /planner/tasks +GET /planner/tasks/{task_id} +PATCH /planner/tasks/{task_id} (volitelně hlavička If-Match) +DELETE /planner/tasks/{task_id} (volitelně hlavička If-Match) ``` +Parametry `start` a `end` u calendarView jsou ISO 8601 s offsetem, například +`2026-09-08T08:00:00+02:00`. Datumy u Planneru (`due_date_time`, `start_date_time`) +jsou ISO 8601 s offsetem, například `2026-09-10T16:00:00Z`. Řešitelé se předávají +jako Graph user id v `assign_user_ids`, odebírají v `unassign_user_ids`. + `user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`. ## Požadovaná Graph oprávnění @@ -208,10 +254,22 @@ Přidělte pouze oprávnění, která dané nasazení skutečně používá: - Users: `User.Read.All` - Čtení mailů: `Mail.Read` - Odesílání mailů: `Mail.Send` -- Čtení a zápis do kalendáře: `Calendars.ReadWrite` +- Čtení a zápis do kalendáře, calendarView, getSchedule: `Calendars.ReadWrite` +- Planner: `Tasks.ReadWrite.All` (zahrnuje i čtení, `Tasks.Read.All` netřeba) - Čtení a zápis do OneDrive: `Files.ReadWrite.All` - Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All` +Pro konektor CSBOT (Calendar + Planner) je minimum `Calendars.ReadWrite` a `Tasks.ReadWrite.All`, +obě jako Application permissions. `Calendars.ReadWrite` platí na všechny mailboxy tenantu, +omezení na konkrétní mailbox se řeší na straně klienta v Exchange Online +(application access policy), ne v této službě. + +## Ukázky pro klienty + +- `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10` + a obsahuje třídu `CredentialEncoder` pro šifrování hlaviček `X-MS365-*`. + Popis v `examples/csharp/README.md`. + ## Lokální spuštění ```bash @@ -223,3 +281,11 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`. # job queue test Thu May 28 02:12:07 PM CEST 2026 # job queue test Thu May 28 02:12:28 PM CEST 2026 # websocket logs test Fri May 29 11:03:58 AM CEST 2026 + +## Záznam změn + +- 2026-09-09: verze 1.1.0. Podle Notion zadání "MS365 práva konektoru" doplněn Calendar + (calendarView, getSchedule, get/patch/delete události), Planner (plány, buckety, tasky, + create/patch/delete s ETag) a `GET /admin-consent/url`. Kód ověřen jen importem aplikace + a serializací schémat, proti reálnému tenantu netestováno (chybí DEV App Registration). +- 2026-09-09: přidána C# ukázka `examples/csharp/ListUsersTop10` (top 10 z `/users`). diff --git a/app.yml b/app.yml index 3eb5c7a..4734042 100644 --- a/app.yml +++ b/app.yml @@ -1,7 +1,7 @@ id: microsoft-365-service name: Microsoft 365 Service language: python -version: 1.0.1 +version: 1.1.0 base_path: /apps/microsoft-365-service port: 8000 status: development diff --git a/app/config.py b/app/config.py index 59032e5..fc6c2cd 100644 --- a/app/config.py +++ b/app/config.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, replace @dataclass(frozen=True) class Settings: app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service") - app_version: str = os.getenv("APP_VERSION", "1.0.1") + app_version: str = os.getenv("APP_VERSION", "1.1.0") root_path: str = os.getenv("ROOT_PATH", "") tenant_id: str = os.getenv("MS365_TENANT_ID", "") client_id: str = os.getenv("MS365_CLIENT_ID", "") diff --git a/app/routes.py b/app/routes.py index 133ab72..d87205d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,12 +1,21 @@ from typing import Any +from urllib.parse import urlencode -from fastapi import APIRouter, Body, Depends, Query, Response, status +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status from .config import Settings from .credentials import get_request_settings from .graph_client import MicrosoftGraphClient -from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest -from .services import CalendarService, DriveService, GroupsService, MailService, UsersService +from .schemas import ( + CalendarEventRequest, + CalendarEventUpdateRequest, + DriveUploadRequest, + PlannerTaskCreateRequest, + PlannerTaskUpdateRequest, + ScheduleRequest, + SendMailRequest, +) +from .services import CalendarService, DriveService, GroupsService, MailService, PlannerService, UsersService router = APIRouter(tags=["microsoft365"]) @@ -35,6 +44,10 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) return GroupsService(graph) +def get_planner_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> PlannerService: + return PlannerService(graph) + + @router.get("/status") def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]: return { @@ -46,6 +59,31 @@ def microsoft365_status(request_settings: Settings = Depends(get_request_setting } +@router.get("/admin-consent/url") +def admin_consent_url( + redirect_uri: str = Query(..., description="HTTPS callback URL registered on the Entra app registration."), + state: str | None = Query(None, description="Opaque value returned to the callback, e.g. customer id."), + request_settings: Settings = Depends(get_request_settings), +) -> dict[str, Any]: + """Build the admin consent URL a client tenant administrator must open to approve the multitenant app.""" + if not request_settings.tenant_id or not request_settings.client_id: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Tenant id and client id are required to build the admin consent URL.", + ) + query: dict[str, str] = { + "client_id": request_settings.client_id, + "scope": request_settings.graph_scope, + "redirect_uri": redirect_uri, + } + if state: + query["state"] = state + return { + "tenant_id": request_settings.tenant_id, + "url": f"https://login.microsoftonline.com/{request_settings.tenant_id}/v2.0/adminconsent?{urlencode(query)}", + } + + @router.get("/users") async def list_users( top: int = Query(25, ge=1, le=999), @@ -99,6 +137,56 @@ async def create_event( return await service.create_event(user_id=user_id, request=request) +@router.get("/users/{user_id}/calendar/view") +async def list_calendar_view( + user_id: str, + start: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00+02:00."), + end: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T18:00:00+02:00."), + top: int = Query(25, ge=1, le=100), + time_zone: str | None = Query(None, description="Time zone for returned dates, e.g. Europe/Prague."), + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.list_calendar_view(user_id=user_id, start=start, end=end, top=top, time_zone=time_zone) + + +@router.post("/users/{user_id}/calendar/schedule") +async def get_schedule( + user_id: str, + request: ScheduleRequest, + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.get_schedule(user_id=user_id, request=request) + + +@router.get("/users/{user_id}/calendar/events/{event_id}") +async def get_event( + user_id: str, + event_id: str, + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.get_event(user_id=user_id, event_id=event_id) + + +@router.patch("/users/{user_id}/calendar/events/{event_id}") +async def update_event( + user_id: str, + event_id: str, + request: CalendarEventUpdateRequest, + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.update_event(user_id=user_id, event_id=event_id, request=request) + + +@router.delete("/users/{user_id}/calendar/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_event( + user_id: str, + event_id: str, + service: CalendarService = Depends(get_calendar_service), +) -> Response: + await service.delete_event(user_id=user_id, event_id=event_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.get("/users/{user_id}/drive/root/children") async def list_drive_root_children( user_id: str, @@ -129,3 +217,59 @@ async def list_groups( @router.get("/teams/{team_id}/channels") async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any: return await service.list_team_channels(team_id=team_id) + + +@router.get("/groups/{group_id}/planner/plans") +async def list_group_plans(group_id: str, service: PlannerService = Depends(get_planner_service)) -> Any: + return await service.list_group_plans(group_id=group_id) + + +@router.get("/planner/plans/{plan_id}/buckets") +async def list_plan_buckets(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any: + return await service.list_plan_buckets(plan_id=plan_id) + + +@router.get("/planner/plans/{plan_id}/tasks") +async def list_plan_tasks(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any: + return await service.list_plan_tasks(plan_id=plan_id) + + +@router.post("/planner/tasks", status_code=status.HTTP_201_CREATED) +async def create_planner_task( + request: PlannerTaskCreateRequest, + service: PlannerService = Depends(get_planner_service), +) -> Any: + return await service.create_task(request=request) + + +@router.get("/planner/tasks/{task_id}") +async def get_planner_task(task_id: str, service: PlannerService = Depends(get_planner_service)) -> Any: + return await service.get_task(task_id=task_id) + + +@router.patch("/planner/tasks/{task_id}") +async def update_planner_task( + task_id: str, + request: PlannerTaskUpdateRequest, + if_match: str | None = Header( + None, + alias="If-Match", + description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.", + ), + service: PlannerService = Depends(get_planner_service), +) -> Any: + return await service.update_task(task_id=task_id, request=request, if_match=if_match) + + +@router.delete("/planner/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_planner_task( + task_id: str, + if_match: str | None = Header( + None, + alias="If-Match", + description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.", + ), + service: PlannerService = Depends(get_planner_service), +) -> Response: + await service.delete_task(task_id=task_id, if_match=if_match) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/schemas.py b/app/schemas.py index 613ed8f..6382179 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -69,6 +69,125 @@ class CalendarEventRequest(BaseModel): online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$") +class CalendarEventUpdateRequest(BaseModel): + """Partial update; only fields that are set are sent to Microsoft Graph.""" + + subject: str | None = None + body: str | None = None + body_content_type: str = Field("HTML", pattern="^(HTML|Text)$") + start: DateTimeTimeZone | None = None + end: DateTimeTimeZone | None = None + location: str | None = None + attendees: list[CalendarAttendee] | None = None + is_online_meeting: bool | None = None + online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$") + + def as_graph_patch(self) -> dict[str, Any]: + payload: dict[str, Any] = {} + if self.subject is not None: + payload["subject"] = self.subject + if self.body is not None: + payload["body"] = {"contentType": self.body_content_type, "content": self.body} + if self.start is not None: + payload["start"] = self.start.model_dump(by_alias=True) + if self.end is not None: + payload["end"] = self.end.model_dump(by_alias=True) + if self.location is not None: + payload["location"] = {"displayName": self.location} + if self.attendees is not None: + payload["attendees"] = [attendee.as_graph_attendee() for attendee in self.attendees] + if self.is_online_meeting is not None: + payload["isOnlineMeeting"] = self.is_online_meeting + if self.online_meeting_provider is not None: + payload["onlineMeetingProvider"] = self.online_meeting_provider + return payload + + +class ScheduleRequest(BaseModel): + """Free/busy lookup (Graph getSchedule) for one or more mailboxes.""" + + schedules: list[EmailStr] = Field(..., min_length=1, description="Mailboxes whose availability is requested.") + start: DateTimeTimeZone + end: DateTimeTimeZone + availability_view_interval: int = Field(30, ge=5, le=1440, description="Slot length in minutes.") + + def as_graph_payload(self) -> dict[str, Any]: + return { + "schedules": [str(address) for address in self.schedules], + "startTime": self.start.model_dump(by_alias=True), + "endTime": self.end.model_dump(by_alias=True), + "availabilityViewInterval": self.availability_view_interval, + } + + +def _planner_assignments(assign_user_ids: list[str], unassign_user_ids: list[str]) -> dict[str, Any]: + assignments: dict[str, Any] = { + user_id: {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"} + for user_id in assign_user_ids + } + for user_id in unassign_user_ids: + assignments[user_id] = None + return assignments + + +class PlannerTaskCreateRequest(BaseModel): + plan_id: str + bucket_id: str | None = None + title: str + start_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00Z.") + due_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-10T16:00:00Z.") + percent_complete: int | None = Field(None, ge=0, le=100) + priority: int | None = Field(None, ge=0, le=10, description="Graph priority: 1 urgent, 3 important, 5 medium, 9 low.") + assign_user_ids: list[str] = Field(default_factory=list, description="Microsoft Graph user ids to assign.") + + def as_graph_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = {"planId": self.plan_id, "title": self.title} + if self.bucket_id: + payload["bucketId"] = self.bucket_id + if self.start_date_time: + payload["startDateTime"] = self.start_date_time + if self.due_date_time: + payload["dueDateTime"] = self.due_date_time + if self.percent_complete is not None: + payload["percentComplete"] = self.percent_complete + if self.priority is not None: + payload["priority"] = self.priority + if self.assign_user_ids: + payload["assignments"] = _planner_assignments(self.assign_user_ids, []) + return payload + + +class PlannerTaskUpdateRequest(BaseModel): + """Partial update; only fields that are set are sent to Microsoft Graph.""" + + title: str | None = None + bucket_id: str | None = None + start_date_time: str | None = None + due_date_time: str | None = None + percent_complete: int | None = Field(None, ge=0, le=100) + priority: int | None = Field(None, ge=0, le=10) + assign_user_ids: list[str] = Field(default_factory=list) + unassign_user_ids: list[str] = Field(default_factory=list) + + def as_graph_patch(self) -> dict[str, Any]: + payload: dict[str, Any] = {} + if self.title is not None: + payload["title"] = self.title + if self.bucket_id is not None: + payload["bucketId"] = self.bucket_id + if self.start_date_time is not None: + payload["startDateTime"] = self.start_date_time + if self.due_date_time is not None: + payload["dueDateTime"] = self.due_date_time + if self.percent_complete is not None: + payload["percentComplete"] = self.percent_complete + if self.priority is not None: + payload["priority"] = self.priority + if self.assign_user_ids or self.unassign_user_ids: + payload["assignments"] = _planner_assignments(self.assign_user_ids, self.unassign_user_ids) + return payload + + class DriveUploadRequest(BaseModel): content_base64: str content_type: str = "application/octet-stream" diff --git a/app/services.py b/app/services.py index d140206..4d922e4 100644 --- a/app/services.py +++ b/app/services.py @@ -6,7 +6,15 @@ from urllib.parse import quote from fastapi import HTTPException, status from .graph_client import MicrosoftGraphClient -from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest +from .schemas import ( + CalendarEventRequest, + CalendarEventUpdateRequest, + DriveUploadRequest, + PlannerTaskCreateRequest, + PlannerTaskUpdateRequest, + ScheduleRequest, + SendMailRequest, +) def graph_segment(value: str) -> str: @@ -74,6 +82,11 @@ class MailService: ) +CALENDAR_EVENT_SELECT = ( + "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting,showAs,isCancelled,organizer" +) + + class CalendarService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph @@ -82,10 +95,66 @@ class CalendarService: params = { "$top": top, "$orderby": "start/dateTime", - "$select": "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting", + "$select": CALENDAR_EVENT_SELECT, } return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params) + async def list_calendar_view( + self, + user_id: str, + start: str, + end: str, + top: int = 25, + time_zone: str | None = None, + ) -> Any: + params = { + "startDateTime": start, + "endDateTime": end, + "$top": top, + "$orderby": "start/dateTime", + "$select": CALENDAR_EVENT_SELECT, + } + headers = {"Prefer": f'outlook.timezone="{time_zone}"'} if time_zone else None + return await self._graph.request( + "GET", + f"/users/{graph_segment(user_id)}/calendarView", + params=params, + headers=headers, + ) + + async def get_schedule(self, user_id: str, request: ScheduleRequest) -> Any: + return await self._graph.request( + "POST", + f"/users/{graph_segment(user_id)}/calendar/getSchedule", + json=request.as_graph_payload(), + ) + + async def get_event(self, user_id: str, event_id: str) -> Any: + return await self._graph.request( + "GET", + f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", + params={"$select": CALENDAR_EVENT_SELECT + ",body"}, + ) + + async def update_event(self, user_id: str, event_id: str, request: CalendarEventUpdateRequest) -> Any: + payload = request.as_graph_patch() + if not payload: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Calendar event update must contain at least one field.", + ) + return await self._graph.request( + "PATCH", + f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", + json=payload, + ) + + async def delete_event(self, user_id: str, event_id: str) -> None: + await self._graph.request( + "DELETE", + f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}", + ) + async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any: payload: dict[str, Any] = { "subject": request.subject, @@ -103,6 +172,63 @@ class CalendarService: return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload) +class PlannerService: + """Microsoft Planner. Updates and deletes require the task ETag (Graph optimistic concurrency).""" + + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_group_plans(self, group_id: str) -> Any: + return await self._graph.request("GET", f"/groups/{graph_segment(group_id)}/planner/plans") + + async def list_plan_buckets(self, plan_id: str) -> Any: + return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/buckets") + + async def list_plan_tasks(self, plan_id: str) -> Any: + return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/tasks") + + async def get_task(self, task_id: str) -> Any: + return await self._graph.request("GET", f"/planner/tasks/{graph_segment(task_id)}") + + async def create_task(self, request: PlannerTaskCreateRequest) -> Any: + return await self._graph.request("POST", "/planner/tasks", json=request.as_graph_payload()) + + async def update_task(self, task_id: str, request: PlannerTaskUpdateRequest, if_match: str | None) -> Any: + payload = request.as_graph_patch() + if not payload: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Planner task update must contain at least one field.", + ) + etag = await self._resolve_etag(task_id, if_match) + return await self._graph.request( + "PATCH", + f"/planner/tasks/{graph_segment(task_id)}", + json=payload, + headers={"If-Match": etag, "Prefer": "return=representation"}, + ) + + async def delete_task(self, task_id: str, if_match: str | None) -> None: + etag = await self._resolve_etag(task_id, if_match) + await self._graph.request( + "DELETE", + f"/planner/tasks/{graph_segment(task_id)}", + headers={"If-Match": etag}, + ) + + async def _resolve_etag(self, task_id: str, if_match: str | None) -> str: + if if_match: + return if_match + task = await self.get_task(task_id) + etag = task.get("@odata.etag") if isinstance(task, dict) else None + if not etag: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={"message": "Microsoft Graph did not return an ETag for the Planner task.", "task_id": task_id}, + ) + return etag + + class DriveService: def __init__(self, graph: MicrosoftGraphClient) -> None: self._graph = graph diff --git a/examples/csharp/ListUsersTop10/ListUsersTop10.csproj b/examples/csharp/ListUsersTop10/ListUsersTop10.csproj new file mode 100644 index 0000000..f704bf4 --- /dev/null +++ b/examples/csharp/ListUsersTop10/ListUsersTop10.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + diff --git a/examples/csharp/ListUsersTop10/Program.cs b/examples/csharp/ListUsersTop10/Program.cs new file mode 100644 index 0000000..db67014 --- /dev/null +++ b/examples/csharp/ListUsersTop10/Program.cs @@ -0,0 +1,128 @@ +// Ukazka volani microsoft-365-service: vypis prvnich 10 uzivatelu z GET /users. +// +// Sluzba: https://services.csbot.cz/apps/microsoft-365-service +// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs +// +// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana +// AES-256-GCM klicem odvozenym pres HKDF-SHA256 ze sdileneho secretu +// (MS365_CREDENTIAL_ENCODING_SECRET na strane sluzby). Format hodnoty: +// v1.. +// +// Spusteni: +// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service +// set MS365_TENANT_ID= +// set MS365_CLIENT_ID= +// set MS365_CLIENT_SECRET= +// set MS365_CREDENTIAL_ENCODING_SECRET= +// dotnet run + +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service"); +var tenantId = Env("MS365_TENANT_ID"); +var clientId = Env("MS365_CLIENT_ID"); +var clientSecret = Env("MS365_CLIENT_SECRET"); +var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET"); + +const int top = 10; + +using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") }; +http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + +using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}"); +request.Headers.Add("X-MS365-Tenant-Id", CredentialEncoder.Encode(tenantId, "X-MS365-Tenant-Id", sharedSecret)); +request.Headers.Add("X-MS365-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret)); +request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret)); +request.Headers.Add("X-MS365-Credential-Version", "v1"); + +using var response = await http.SendAsync(request); +var body = await response.Content.ReadAsStringAsync(); + +if (!response.IsSuccessStatusCode) +{ + // 400 = spatne/prosle hlavicky, 502 = chyba z Microsoft Graph (detail je v tele), + // 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET). + Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + Console.Error.WriteLine(body); + return 1; +} + +// Sluzba vraci odpoved Microsoft Graph beze zmeny: { "value": [ { ...user... } ], "@odata.nextLink": "..." } +using var json = JsonDocument.Parse(body); +var users = json.RootElement.GetProperty("value"); + +Console.WriteLine($"Nacteno uzivatelu: {users.GetArrayLength()}"); +foreach (var user in users.EnumerateArray()) +{ + var displayName = user.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; + var upn = user.TryGetProperty("userPrincipalName", out var up) ? up.GetString() : null; + var mail = user.TryGetProperty("mail", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null; + var id = user.GetProperty("id").GetString(); + + Console.WriteLine($"{displayName} | {upn} | {mail ?? "-"} | {id}"); +} + +return 0; + +static string Env(string name, string? fallback = null) +{ + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + if (fallback is not null) + { + return fallback; + } + throw new InvalidOperationException($"Chybi environment promenna {name}."); +} + +/// +/// Sifrovani hodnot pro hlavicky X-MS365-*. Musi presne odpovidat dekodovani na strane sluzby +/// (app/credentials.py): HKDF-SHA256 se salt "microsoft-365-service.credentials.v1" +/// a info = nazev hlavicky, AES-256-GCM s 12B nonce, 16B tagem a AAD = nazev hlavicky. +/// +static class CredentialEncoder +{ + private static readonly byte[] HkdfSalt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1"); + + public static string Encode(string value, string headerName, string sharedSecret, TimeSpan? validity = null) + { + var now = DateTimeOffset.UtcNow; + var payload = JsonSerializer.Serialize(new + { + value, + issued_at = now.ToString("O"), + expires_at = now.Add(validity ?? TimeSpan.FromHours(1)).ToString("O"), + }); + + var headerBytes = Encoding.UTF8.GetBytes(headerName); + var key = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + Encoding.UTF8.GetBytes(sharedSecret), + outputLength: 32, + salt: HkdfSalt, + info: headerBytes); + + var nonce = RandomNumberGenerator.GetBytes(12); + var plaintext = Encoding.UTF8.GetBytes(payload); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + + using var aes = new AesGcm(key, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData: headerBytes); + + var ciphertextAndTag = new byte[ciphertext.Length + tag.Length]; + ciphertext.CopyTo(ciphertextAndTag, 0); + tag.CopyTo(ciphertextAndTag, ciphertext.Length); + + return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}"; + } + + private static string Base64Url(byte[] data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs new file mode 100644 index 0000000..2217181 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")] diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs new file mode 100644 index 0000000..b3bf715 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: System.Reflection.AssemblyCompanyAttribute("ListUsersTop10")] +[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+275bd6c46764ec8db008fa2fa8c30673bb96f3d8")] +[assembly: System.Reflection.AssemblyProductAttribute("ListUsersTop10")] +[assembly: System.Reflection.AssemblyTitleAttribute("ListUsersTop10")] +[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] + +// Generated by the MSBuild WriteCodeFragment class. + diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache new file mode 100644 index 0000000..48ca0b9 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0 diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig new file mode 100644 index 0000000..7dd5c4e --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig @@ -0,0 +1,18 @@ +is_global = true +build_property.TargetFramework = net8.0 +build_property.TargetFrameworkIdentifier = .NETCoreApp +build_property.TargetFrameworkVersion = v8.0 +build_property.TargetPlatformMinVersion = +build_property.UsingMicrosoftNETSdkWeb = +build_property.ProjectTypeGuids = +build_property.InvariantGlobalization = +build_property.PlatformNeutralAssembly = +build_property.EnforceExtendedAnalyzerRules = +build_property.EntryPointFilePath = +build_property._SupportedPlatformList = Linux,macOS,Windows +build_property.RootNamespace = ListUsersTop10 +build_property.ProjectDir = D:\GitHubRepository\Hracicky\x\ms365\microsoft-365-service\examples\csharp\ListUsersTop10\ +build_property.EnableComHosting = +build_property.EnableGeneratedComInterfaceComImportInterop = +build_property.EffectiveAnalysisLevelStyle = 8.0 +build_property.EnableCodeStyleSeverity = diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs new file mode 100644 index 0000000..d12bcbc --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs @@ -0,0 +1,8 @@ +// +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache new file mode 100644 index 0000000..297ea05 Binary files /dev/null and b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache differ diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json new file mode 100644 index 0000000..80ce806 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json @@ -0,0 +1,77 @@ +{ + "format": 1, + "restore": { + "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {} + }, + "projects": { + "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj", + "projectName": "ListUsersTop10", + "projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj", + "packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\", + "outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\", + "projectStyle": "PackageReference", + "fallbackFolders": [ + "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" + ], + "configFilePaths": [ + "C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" + ], + "originalTargetFrameworks": [ + "net8.0" + ], + "sources": { + "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, + "C:\\Program Files\\dotnet\\library-packs": {}, + "D:\\CustomNuGetPackages": {}, + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net8.0": { + "framework": "net8.0", + "targetAlias": "net8.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + }, + "SdkAnalysisLevel": "10.0.400" + }, + "frameworks": { + "net8.0": { + "framework": "net8.0", + "targetAlias": "net8.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json" + } + } + } + } +} \ No newline at end of file diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props new file mode 100644 index 0000000..31462d3 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props @@ -0,0 +1,16 @@ + + + + True + NuGet + $(MSBuildThisFileDirectory)project.assets.json + $(UserProfile)\.nuget\packages\ + C:\Users\GamingPC\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages + PackageReference + 7.0.0 + + + + + + \ No newline at end of file diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets new file mode 100644 index 0000000..3dc06ef --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/examples/csharp/ListUsersTop10/obj/project.assets.json b/examples/csharp/ListUsersTop10/obj/project.assets.json new file mode 100644 index 0000000..8d2ed4e --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/project.assets.json @@ -0,0 +1,83 @@ +{ + "version": 4, + "targets": { + "net8.0": {} + }, + "libraries": {}, + "projectFileDependencyGroups": { + "net8.0": [] + }, + "packageFolders": { + "C:\\Users\\GamingPC\\.nuget\\packages\\": {}, + "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {} + }, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj", + "projectName": "ListUsersTop10", + "projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj", + "packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\", + "outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\", + "projectStyle": "PackageReference", + "fallbackFolders": [ + "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages" + ], + "configFilePaths": [ + "C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config", + "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config" + ], + "originalTargetFrameworks": [ + "net8.0" + ], + "sources": { + "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {}, + "C:\\Program Files\\dotnet\\library-packs": {}, + "D:\\CustomNuGetPackages": {}, + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net8.0": { + "framework": "net8.0", + "targetAlias": "net8.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + }, + "SdkAnalysisLevel": "10.0.400" + }, + "frameworks": { + "net8.0": { + "framework": "net8.0", + "targetAlias": "net8.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json" + } + } + } +} \ No newline at end of file diff --git a/examples/csharp/ListUsersTop10/obj/project.nuget.cache b/examples/csharp/ListUsersTop10/obj/project.nuget.cache new file mode 100644 index 0000000..0163ef1 --- /dev/null +++ b/examples/csharp/ListUsersTop10/obj/project.nuget.cache @@ -0,0 +1,8 @@ +{ + "version": 2, + "dgSpecHash": "4FfrW7UlSBU=", + "success": true, + "projectFilePath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj", + "expectedPackageFiles": [], + "logs": [] +} \ No newline at end of file diff --git a/examples/csharp/README.md b/examples/csharp/README.md new file mode 100644 index 0000000..bce7c23 --- /dev/null +++ b/examples/csharp/README.md @@ -0,0 +1,36 @@ +# Ukazky volani sluzby v C# + +## ListUsersTop10 + +Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise +displayName, userPrincipalName, mail a id kazdeho uzivatele. + +Obsahuje tridu `CredentialEncoder`, ktera sifruje hodnoty pro hlavicky +`X-MS365-Tenant-Id`, `X-MS365-Client-Id` a `X-MS365-Client-Secret` presne tak, +jak je sluzba dekoduje v `app/credentials.py`. Tridu lze zkopirovat do +libovolneho projektu, nema zadne externi zavislosti. + +### Spusteni + +```powershell +cd examples/csharp/ListUsersTop10 +$env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service" +$env:MS365_TENANT_ID = "" +$env:MS365_CLIENT_ID = "" +$env:MS365_CLIENT_SECRET = "" +$env:MS365_CREDENTIAL_ENCODING_SECRET = "" +dotnet run +``` + +### Co ocekavat + +- `200` a JSON ve tvaru Microsoft Graph: `{ "value": [ ... ], "@odata.nextLink": "..." }`. +- `400` kdyz hlavicky chybi, jsou neuplne, prosle nebo je spatny sdileny secret. +- `502` kdyz Microsoft Graph nebo prihlaseni k Entra ID selze, detail je v tele odpovedi. +- `503` kdyz sluzba nema nastaveny `MS365_CREDENTIAL_ENCODING_SECRET`. +- `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem + neni ve sluzbe, ale v sitovem pristupu klienta. + +Hlavicky lze vynechat jen tehdy, kdyz ma sluzba credentials nastavene +v environment promennych na serveru. Instance na services.csbot.cz je nema +(`GET /status` vraci `configured: false`), takze tam jsou hlavicky povinne.