scalio-m365 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalio-m365
3
+ Version: 0.1.0
4
+ Summary: Scalio: write Microsoft 365 settings (Entra, SharePoint, Intune, Exchange) to a live tenant, shared by Foundry and Citadel
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: ruff>=0.6; extra == "dev"
11
+
12
+ # scalio-m365
13
+
14
+ Shared code that writes Microsoft 365 settings to a live tenant («Scalio Schreiben»): one writer per
15
+ Microsoft365DSC resource type (Entra ID, SharePoint, Intune, Exchange Online / Defender for Office 365), with the
16
+ property names of the governance catalog (scalio-catalog). Foundry rolls baselines out with it, Citadel heals
17
+ deviations. Decision: scalio-docs `concepts/m365-schreibpaket.md` (`m365pkg/P1`–`P4`), ADR 0089.
18
+
19
+ Nothing in this package is secret and it holds no content: the baselines stay in the private repo
20
+ `scalio_blueprint`, tokens and certificates stay in the app (scalio-tenant).
21
+
22
+ ## Use in an app
23
+
24
+ ```python
25
+ import scalio_m365 as m365
26
+
27
+ g = m365.Graph(call, exo, create=True) # Foundry; Citadel: create=False (keeps existing objects only)
28
+ reason = m365.why_not(type_, desired) # Citadel: why_not(type_, desired, change_only=True)
29
+ actual = await m365.read_one(g, type_, match) # catalog names
30
+ after = m365.fields(type_, {**(actual or {}), **desired})
31
+ if not m365.same(actual, after):
32
+ await m365.kind(type_).write(g, actual, after)
33
+ ```
34
+
35
+ - `call(method, path, body) -> dict`: one Microsoft Graph call (`path` relative to `/v1.0`, or a full
36
+ `@odata.nextLink`); raise on an error. `exo(cmdlet, parameters) -> list[dict]`: one cmdlet through the Exchange
37
+ admin API (`InvokeCommand`, app-only token). The app brings token, HTTP client and logging.
38
+ - `WriteError`: the step cannot be written as asked (missing group, empty break-glass group, object missing,
39
+ no reviewer, no notification address); the text says what to do.
40
+ - `SCOPES` (Graph permissions), `exchange.SCOPES`, `DIRECTORY_ROLES`, `READ_ALONG` and `needs(type_)`: what the
41
+ «Scalio Schreiben» registration needs.
42
+ - Exchange in a fresh tenant: `exchange.prepare(g, is_dehydrated=..., customised_at=...)` runs
43
+ `Enable-OrganizationCustomization` once and says when to wait; the app stores the time.
44
+ - `scalio_m365.mapping`: pure name mappings (Graph answer -> catalog names) for the apps' readers.
45
+ - `scalio_m365.testing.FakeMicrosoft`: fake Graph and Exchange for the apps' tests.
46
+
47
+ ## Modes
48
+
49
+ | | Foundry (`create=True`) | Citadel (`create=False`, `change_only=True`) |
50
+ | --- | --- | --- |
51
+ | Entra, SharePoint | creates or changes | creates or changes |
52
+ | Intune policy missing | creates (compliance with a block action) | refuses: create it with its assignments first |
53
+ | Exchange policy missing | Defender policies with a rule per accepted domain, DKIM | refuses |
54
+ | Names of Intune/Exchange objects | set on create | never changed (`why_not` says so) |
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ pip install -e ".[dev]"
60
+ pytest && ruff check .
61
+ ```
62
+
63
+ Release: bump `pyproject.toml` and `__version__`, reserve the version in scalio-docs `REGISTER.md`, run
64
+ `tools/changelog.py --repo scalio-m365 release <version>` there, then «Freigeben» in the cockpit (workflow
65
+ `publish.yml`, PyPI trusted publishing, environment `pypi`). Tags without `v`.
@@ -0,0 +1,54 @@
1
+ # scalio-m365
2
+
3
+ Shared code that writes Microsoft 365 settings to a live tenant («Scalio Schreiben»): one writer per
4
+ Microsoft365DSC resource type (Entra ID, SharePoint, Intune, Exchange Online / Defender for Office 365), with the
5
+ property names of the governance catalog (scalio-catalog). Foundry rolls baselines out with it, Citadel heals
6
+ deviations. Decision: scalio-docs `concepts/m365-schreibpaket.md` (`m365pkg/P1`–`P4`), ADR 0089.
7
+
8
+ Nothing in this package is secret and it holds no content: the baselines stay in the private repo
9
+ `scalio_blueprint`, tokens and certificates stay in the app (scalio-tenant).
10
+
11
+ ## Use in an app
12
+
13
+ ```python
14
+ import scalio_m365 as m365
15
+
16
+ g = m365.Graph(call, exo, create=True) # Foundry; Citadel: create=False (keeps existing objects only)
17
+ reason = m365.why_not(type_, desired) # Citadel: why_not(type_, desired, change_only=True)
18
+ actual = await m365.read_one(g, type_, match) # catalog names
19
+ after = m365.fields(type_, {**(actual or {}), **desired})
20
+ if not m365.same(actual, after):
21
+ await m365.kind(type_).write(g, actual, after)
22
+ ```
23
+
24
+ - `call(method, path, body) -> dict`: one Microsoft Graph call (`path` relative to `/v1.0`, or a full
25
+ `@odata.nextLink`); raise on an error. `exo(cmdlet, parameters) -> list[dict]`: one cmdlet through the Exchange
26
+ admin API (`InvokeCommand`, app-only token). The app brings token, HTTP client and logging.
27
+ - `WriteError`: the step cannot be written as asked (missing group, empty break-glass group, object missing,
28
+ no reviewer, no notification address); the text says what to do.
29
+ - `SCOPES` (Graph permissions), `exchange.SCOPES`, `DIRECTORY_ROLES`, `READ_ALONG` and `needs(type_)`: what the
30
+ «Scalio Schreiben» registration needs.
31
+ - Exchange in a fresh tenant: `exchange.prepare(g, is_dehydrated=..., customised_at=...)` runs
32
+ `Enable-OrganizationCustomization` once and says when to wait; the app stores the time.
33
+ - `scalio_m365.mapping`: pure name mappings (Graph answer -> catalog names) for the apps' readers.
34
+ - `scalio_m365.testing.FakeMicrosoft`: fake Graph and Exchange for the apps' tests.
35
+
36
+ ## Modes
37
+
38
+ | | Foundry (`create=True`) | Citadel (`create=False`, `change_only=True`) |
39
+ | --- | --- | --- |
40
+ | Entra, SharePoint | creates or changes | creates or changes |
41
+ | Intune policy missing | creates (compliance with a block action) | refuses: create it with its assignments first |
42
+ | Exchange policy missing | Defender policies with a rule per accepted domain, DKIM | refuses |
43
+ | Names of Intune/Exchange objects | set on create | never changed (`why_not` says so) |
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ pip install -e ".[dev]"
49
+ pytest && ruff check .
50
+ ```
51
+
52
+ Release: bump `pyproject.toml` and `__version__`, reserve the version in scalio-docs `REGISTER.md`, run
53
+ `tools/changelog.py --repo scalio-m365 release <version>` there, then «Freigeben» in the cockpit (workflow
54
+ `publish.yml`, PyPI trusted publishing, environment `pypi`). Tags without `v`.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scalio-m365"
7
+ version = "0.1.0"
8
+ description = "Scalio: write Microsoft 365 settings (Entra, SharePoint, Intune, Exchange) to a live tenant, shared by Foundry and Citadel"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "ruff>=0.6"]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+
23
+ [tool.ruff]
24
+ line-length = 120
25
+ target-version = "py311"
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "W", "I", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ """Writing Microsoft 365 settings to a live tenant («Scalio Schreiben»), shared by Foundry and Citadel.
2
+
3
+ One writer per Microsoft365DSC resource type, with the catalog's property names (scalio-catalog). Foundry rolls
4
+ baselines out (`Graph(create=True)`), Citadel heals deviations of existing objects (`Graph(create=False)` and
5
+ `why_not(..., change_only=True)`). The apps bring the transport and keep their shells (rollout, healing, log).
6
+ Decision: scalio-docs concepts/m365-schreibpaket.md (m365pkg/P1-P4), ADR 0089.
7
+ """
8
+ from . import entra, exchange, intune, mapping, sharepoint
9
+ from .graph import Graph, WriteError
10
+ from .table import (
11
+ ALL_SCOPES,
12
+ DIRECTORY_ROLES,
13
+ READ_ALONG,
14
+ SCOPES,
15
+ WRITERS,
16
+ Kind,
17
+ creates,
18
+ fields,
19
+ is_exchange,
20
+ kind,
21
+ matches,
22
+ needs,
23
+ read_one,
24
+ same,
25
+ why_not,
26
+ )
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "ALL_SCOPES", "DIRECTORY_ROLES", "READ_ALONG", "SCOPES", "WRITERS", "Graph", "Kind", "WriteError", "creates",
32
+ "entra", "exchange", "fields", "intune", "is_exchange", "kind", "mapping", "matches", "needs", "read_one", "same",
33
+ "sharepoint", "why_not",
34
+ ]
@@ -0,0 +1,416 @@
1
+ """Entra ID writers through Microsoft Graph: conditional access, authorization policy, authentication methods, admin
2
+ consent workflow, Microsoft 365 group settings and group expiration.
3
+
4
+ Every writer has a reader that answers with the catalog's property names, so `same()` can compare both sides.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from .graph import Graph, WriteError
11
+ from .mapping import (
12
+ GUEST_ROLES,
13
+ LOCATION_WORDS,
14
+ REVIEWER_GROUP,
15
+ ca_extra,
16
+ changed,
17
+ consent_reviewers,
18
+ feature_settings,
19
+ group_query,
20
+ lower,
21
+ norm,
22
+ upper,
23
+ )
24
+
25
+ # ------------------------------------------------------------------ conditional access
26
+
27
+ CA_PATH = "/identity/conditionalAccess/policies"
28
+ CA_FIELDS = frozenset({
29
+ "DisplayName", "Id", "State", "IncludeApplications", "ExcludeApplications", "IncludeUserActions",
30
+ "IncludeUsers", "ExcludeUsers", "IncludeGroups", "ExcludeGroups", "IncludeRoles", "ExcludeRoles",
31
+ "IncludePlatforms", "ExcludePlatforms", "IncludeLocations", "ExcludeLocations", "ClientAppTypes",
32
+ "UserRiskLevels", "SignInRiskLevels", "TransferMethods", "GrantControlOperator", "BuiltInControls",
33
+ "AuthenticationStrength", "SignInFrequencyIsEnabled", "SignInFrequencyValue", "SignInFrequencyType",
34
+ "PersistentBrowserIsEnabled", "PersistentBrowserMode",
35
+ "IncludeGuestOrExternalUserTypes", "ExcludeGuestOrExternalUserTypes", "ContinuousAccessEvaluationMode",
36
+ "ApplicationEnforcedRestrictionsIsEnabled", "SignInFrequencyInterval"})
37
+
38
+
39
+ def _guests(types) -> dict | None:
40
+ if not types:
41
+ return None
42
+ return {"guestOrExternalUserTypes": ",".join(types),
43
+ "externalTenants": {"@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants",
44
+ "membershipKind": "all"}}
45
+
46
+
47
+ async def ca_props(g: Graph, p: dict) -> dict:
48
+ """One policy as Graph answers it -> catalog names (ids become names)."""
49
+ c = p.get("conditions") or {}
50
+ apps, users = c.get("applications") or {}, c.get("users") or {}
51
+ platforms, locations = c.get("platforms") or {}, c.get("locations") or {}
52
+ grant, session = p.get("grantControls") or {}, p.get("sessionControls") or {}
53
+ freq, browser = session.get("signInFrequency") or {}, session.get("persistentBrowser") or {}
54
+ strength = grant.get("authenticationStrength") or {}
55
+ roles, locs = await g.roles(), await g.locations()
56
+
57
+ def loc(x):
58
+ return x if x in LOCATION_WORDS else locs.get(x, x)
59
+ return {
60
+ "DisplayName": p.get("displayName"), "Id": p.get("id"), "State": p.get("state"),
61
+ "IncludeApplications": apps.get("includeApplications") or [],
62
+ "ExcludeApplications": apps.get("excludeApplications") or [],
63
+ "IncludeUserActions": apps.get("includeUserActions") or [],
64
+ "IncludeUsers": [await g.user_name(x) for x in users.get("includeUsers") or []],
65
+ "ExcludeUsers": [await g.user_name(x) for x in users.get("excludeUsers") or []],
66
+ "IncludeGroups": [await g.group_name(x) for x in users.get("includeGroups") or []],
67
+ "ExcludeGroups": [await g.group_name(x) for x in users.get("excludeGroups") or []],
68
+ "IncludeRoles": [roles.get(x, x) for x in users.get("includeRoles") or []],
69
+ "ExcludeRoles": [roles.get(x, x) for x in users.get("excludeRoles") or []],
70
+ "IncludePlatforms": platforms.get("includePlatforms") or [],
71
+ "ExcludePlatforms": platforms.get("excludePlatforms") or [],
72
+ "IncludeLocations": [loc(x) for x in locations.get("includeLocations") or []],
73
+ "ExcludeLocations": [loc(x) for x in locations.get("excludeLocations") or []],
74
+ "ClientAppTypes": c.get("clientAppTypes") or [],
75
+ "UserRiskLevels": c.get("userRiskLevels") or [],
76
+ "SignInRiskLevels": c.get("signInRiskLevels") or [],
77
+ "TransferMethods": (c.get("authenticationFlows") or {}).get("transferMethods"),
78
+ "GrantControlOperator": grant.get("operator"),
79
+ "BuiltInControls": grant.get("builtInControls") or [],
80
+ "AuthenticationStrength": strength.get("displayName"),
81
+ "SignInFrequencyIsEnabled": bool(freq.get("isEnabled")),
82
+ "SignInFrequencyValue": freq.get("value"),
83
+ "SignInFrequencyType": upper(freq.get("type")),
84
+ "PersistentBrowserIsEnabled": bool(browser.get("isEnabled")),
85
+ "PersistentBrowserMode": upper(browser.get("mode")),
86
+ **ca_extra(p),
87
+ }
88
+
89
+
90
+ async def read_ca(g: Graph) -> list[dict]:
91
+ return [await ca_props(g, p) for p in await g.all(CA_PATH)]
92
+
93
+
94
+ def _ids(names, table: dict[str, str], what: str, words=frozenset()) -> list[str]:
95
+ """Display names to ids ("Global Administrator" and "GlobalAdministrator" both match)."""
96
+ by_name = {norm(v): k for k, v in table.items()}
97
+ out = []
98
+ for n in names or []:
99
+ if n in words or n in table:
100
+ out.append(n)
101
+ elif norm(n) in by_name:
102
+ out.append(by_name[norm(n)])
103
+ else:
104
+ raise WriteError(f"{what} '{n}' is not known in the tenant.")
105
+ return out
106
+
107
+
108
+ async def ca_body(g: Graph, p: dict) -> dict:
109
+ """Catalog names -> the Graph body of a policy (names become ids)."""
110
+ roles, locs = await g.roles(), await g.locations()
111
+ users = {
112
+ "includeUsers": [await g.user_id(x) for x in p.get("IncludeUsers") or []],
113
+ "excludeUsers": [await g.user_id(x) for x in p.get("ExcludeUsers") or []],
114
+ "includeGroups": [await g.group_id(x) for x in p.get("IncludeGroups") or []],
115
+ "excludeGroups": [await g.group_id(x) for x in p.get("ExcludeGroups") or []],
116
+ "includeRoles": _ids(p.get("IncludeRoles"), roles, "Role"),
117
+ "excludeRoles": _ids(p.get("ExcludeRoles"), roles, "Role"),
118
+ }
119
+ for key, prop in (("includeGuestsOrExternalUsers", "IncludeGuestOrExternalUserTypes"),
120
+ ("excludeGuestsOrExternalUsers", "ExcludeGuestOrExternalUserTypes")):
121
+ if _guests(p.get(prop)):
122
+ users[key] = _guests(p.get(prop))
123
+ if not any(users.get(k) for k in ("includeUsers", "includeGroups", "includeRoles", "includeGuestsOrExternalUsers")):
124
+ users["includeUsers"] = ["None"]
125
+ conditions: dict[str, Any] = {
126
+ "applications": {"includeApplications": p.get("IncludeApplications") or [],
127
+ "excludeApplications": p.get("ExcludeApplications") or [],
128
+ "includeUserActions": p.get("IncludeUserActions") or []},
129
+ "users": users,
130
+ "clientAppTypes": p.get("ClientAppTypes") or ["all"],
131
+ "userRiskLevels": p.get("UserRiskLevels") or [],
132
+ "signInRiskLevels": p.get("SignInRiskLevels") or [],
133
+ }
134
+ if p.get("IncludePlatforms"):
135
+ conditions["platforms"] = {"includePlatforms": p["IncludePlatforms"],
136
+ "excludePlatforms": p.get("ExcludePlatforms") or []}
137
+ if p.get("IncludeLocations"):
138
+ conditions["locations"] = {
139
+ "includeLocations": _ids(p["IncludeLocations"], locs, "Named location", LOCATION_WORDS),
140
+ "excludeLocations": _ids(p.get("ExcludeLocations"), locs, "Named location", LOCATION_WORDS)}
141
+ if p.get("TransferMethods"):
142
+ conditions["authenticationFlows"] = {"transferMethods": p["TransferMethods"]}
143
+ body: dict[str, Any] = {"displayName": p["DisplayName"],
144
+ "state": p.get("State") or "enabledForReportingButNotEnforced", "conditions": conditions}
145
+ if p.get("BuiltInControls") or p.get("AuthenticationStrength"):
146
+ grant: dict[str, Any] = {"operator": p.get("GrantControlOperator") or "OR",
147
+ "builtInControls": p.get("BuiltInControls") or []}
148
+ if p.get("AuthenticationStrength"):
149
+ grant["authenticationStrength"] = {"id": _ids([p["AuthenticationStrength"]], await g.strengths(),
150
+ "Authentication strength")[0]}
151
+ body["grantControls"] = grant
152
+ else:
153
+ body["grantControls"] = None
154
+ session: dict[str, Any] = {}
155
+ if p.get("SignInFrequencyIsEnabled") and p.get("SignInFrequencyInterval") == "everyTime":
156
+ # Every time: Graph wants no value and no type (e.g. MFA for every Intune enrollment)
157
+ session["signInFrequency"] = {"isEnabled": True, "frequencyInterval": "everyTime",
158
+ "authenticationType": "primaryAndSecondaryAuthentication"}
159
+ elif p.get("SignInFrequencyIsEnabled"):
160
+ session["signInFrequency"] = {"isEnabled": True, "value": p.get("SignInFrequencyValue"),
161
+ "type": lower(p.get("SignInFrequencyType")), "frequencyInterval": "timeBased"}
162
+ if p.get("PersistentBrowserIsEnabled"):
163
+ session["persistentBrowser"] = {"isEnabled": True, "mode": lower(p.get("PersistentBrowserMode"))}
164
+ if p.get("ContinuousAccessEvaluationMode") and p["ContinuousAccessEvaluationMode"] != "disabled":
165
+ session["continuousAccessEvaluation"] = {"mode": p["ContinuousAccessEvaluationMode"]}
166
+ if p.get("ApplicationEnforcedRestrictionsIsEnabled"):
167
+ session["applicationEnforcedRestrictions"] = {"isEnabled": True}
168
+ body["sessionControls"] = session or None
169
+ return body
170
+
171
+
172
+ async def write_ca(g: Graph, actual: dict | None, after: dict) -> str:
173
+ if after.get("State") == "enabled" and after.get("ExcludeGroups"):
174
+ # An enforced policy must leave the break-glass accounts out, and they must exist.
175
+ total = 0
176
+ for name in after["ExcludeGroups"]:
177
+ total += await g.members(await g.group_id(name))
178
+ if not total:
179
+ raise WriteError(f"The excluded group(s) {', '.join(after['ExcludeGroups'])} have no members. Add the "
180
+ f"break-glass accounts first, so an enforced policy cannot lock everybody out.")
181
+ body = await ca_body(g, after)
182
+ if actual and actual.get("Id"):
183
+ await g.send("PATCH", f"{CA_PATH}/{actual['Id']}", body)
184
+ return actual["Id"]
185
+ return (await g.send("POST", CA_PATH, body)).get("id")
186
+
187
+
188
+ # ------------------------------------------------------------------ authorization policy
189
+
190
+ AUTHZ_PATH = "/policies/authorizationPolicy"
191
+ AUTHZ_FIELDS = frozenset({
192
+ "IsSingleInstance", "AllowInvitesFrom", "AllowedToSignUpEmailBasedSubscriptions", "AllowedToUseSSPR",
193
+ "AllowEmailVerifiedUsersToJoinOrganization", "BlockMsolPowerShell", "GuestUserRole",
194
+ "PermissionGrantPolicyIdsAssignedToDefaultUserRole", "DefaultUserRolePermissions"})
195
+ AUTHZ_SIMPLE = {"AllowedToSignUpEmailBasedSubscriptions": "allowedToSignUpEmailBasedSubscriptions",
196
+ "AllowedToUseSSPR": "allowedToUseSSPR", "BlockMsolPowerShell": "blockMsolPowerShell",
197
+ "AllowEmailVerifiedUsersToJoinOrganization": "allowEmailVerifiedUsersToJoinOrganization"}
198
+ USER_PERMS = ("AllowedToCreateApps", "AllowedToCreateSecurityGroups", "AllowedToCreateTenants",
199
+ "AllowedToReadBitlockerKeysForOwnedDevice", "AllowedToReadOtherUsers")
200
+
201
+
202
+ async def read_authorization(g: Graph) -> list[dict]:
203
+ p = await g.get(AUTHZ_PATH)
204
+ if "value" in p: # some tenants answer with a collection
205
+ p = (p["value"] or [{}])[0]
206
+ d = p.get("defaultUserRolePermissions") or {}
207
+ return [{
208
+ "IsSingleInstance": "Yes",
209
+ "AllowInvitesFrom": upper(p.get("allowInvitesFrom")),
210
+ **{dsc: p.get(graph) for dsc, graph in AUTHZ_SIMPLE.items()},
211
+ "GuestUserRole": GUEST_ROLES.get(p.get("guestUserRoleId") or "", p.get("guestUserRoleId")),
212
+ "PermissionGrantPolicyIdsAssignedToDefaultUserRole": d.get("permissionGrantPoliciesAssigned") or [],
213
+ "DefaultUserRolePermissions": {k: d.get(lower(k)) for k in USER_PERMS},
214
+ }]
215
+
216
+
217
+ async def write_authorization(g: Graph, actual: dict | None, after: dict) -> str:
218
+ diff = changed(actual, after)
219
+ body: dict[str, Any] = {}
220
+ for dsc, graph in AUTHZ_SIMPLE.items():
221
+ if dsc in diff:
222
+ body[graph] = diff[dsc]
223
+ if "AllowInvitesFrom" in diff:
224
+ body["allowInvitesFrom"] = lower(diff["AllowInvitesFrom"])
225
+ if "GuestUserRole" in diff:
226
+ ids = {v: k for k, v in GUEST_ROLES.items()}
227
+ if diff["GuestUserRole"] not in ids:
228
+ raise WriteError(f"Unknown guest user role '{diff['GuestUserRole']}'.")
229
+ body["guestUserRoleId"] = ids[diff["GuestUserRole"]]
230
+ perms: dict[str, Any] = {}
231
+ if "DefaultUserRolePermissions" in diff:
232
+ perms.update({lower(k): v for k, v in (diff["DefaultUserRolePermissions"] or {}).items() if v is not None})
233
+ if "PermissionGrantPolicyIdsAssignedToDefaultUserRole" in diff:
234
+ perms["permissionGrantPoliciesAssigned"] = diff["PermissionGrantPolicyIdsAssignedToDefaultUserRole"]
235
+ if perms:
236
+ body["defaultUserRolePermissions"] = perms
237
+ if body:
238
+ await g.send("PATCH", AUTHZ_PATH, body)
239
+ return "authorizationPolicy"
240
+
241
+
242
+ # ------------------------------------------------------------------ authentication methods
243
+
244
+ AUTHN_BASE = "/policies/authenticationMethodsPolicy/authenticationMethodConfigurations"
245
+
246
+
247
+ def auth_method(method: str, odata: str):
248
+ """Reader and writer of one authentication method. Microsoft Authenticator also has feature settings (number
249
+ matching context, location, companion app); a changed one keeps its include/exclude targets."""
250
+ path = f"{AUTHN_BASE}/{method}"
251
+
252
+ async def read(g: Graph) -> list[dict]:
253
+ p = await g.get(path)
254
+ row = {"Id": p.get("id") or method, "State": p.get("state")}
255
+ if method == "MicrosoftAuthenticator":
256
+ row["FeatureSettings"] = feature_settings(p)
257
+ return [row]
258
+
259
+ async def write(g: Graph, actual: dict | None, after: dict) -> str:
260
+ body: dict[str, Any] = {}
261
+ if after.get("State") and (actual or {}).get("State") != after.get("State"):
262
+ body["state"] = after["State"]
263
+ have = (actual or {}).get("FeatureSettings") or {}
264
+ diff = {k: v for k, v in (after.get("FeatureSettings") or {}).items()
265
+ if (have.get(k) or {}).get("State") != (v or {}).get("State")}
266
+ if diff:
267
+ raw = (await g.get(path)).get("featureSettings") or {}
268
+ body["featureSettings"] = {}
269
+ for key, want in diff.items():
270
+ name = key[0].lower() + key[1:]
271
+ cur = dict(raw.get(name) or {})
272
+ cur.setdefault("includeTarget", {"targetType": "group", "id": "all_users"})
273
+ body["featureSettings"][name] = {**cur, "state": want["State"]}
274
+ if body:
275
+ if "state" not in body:
276
+ body["state"] = (actual or {}).get("State") or "enabled"
277
+ await g.send("PATCH", path, {"@odata.type": f"#microsoft.graph.{odata}", **body})
278
+ return method
279
+ return read, write
280
+
281
+
282
+ # ------------------------------------------------------------------ admin consent workflow
283
+
284
+ CONSENT_PATH = "/policies/adminConsentRequestPolicy"
285
+ CONSENT_FIELDS = frozenset({"IsSingleInstance", "IsEnabled", "NotifyReviewers", "RemindersEnabled",
286
+ "RequestDurationInDays", "Reviewers"})
287
+
288
+
289
+ async def read_admin_consent(g: Graph) -> list[dict]:
290
+ p = await g.get(CONSENT_PATH)
291
+ names = {}
292
+ for r in p.get("reviewers") or []:
293
+ m = REVIEWER_GROUP.match((r or {}).get("query") or "")
294
+ if m:
295
+ names[m[1]] = await g.group_name(m[1])
296
+ return [{"IsSingleInstance": "Yes", "IsEnabled": p.get("isEnabled"), "NotifyReviewers": p.get("notifyReviewers"),
297
+ "RemindersEnabled": p.get("remindersEnabled"), "RequestDurationInDays": p.get("requestDurationInDays"),
298
+ "Reviewers": consent_reviewers(p.get("reviewers"), lambda gid: names.get(gid, gid))}]
299
+
300
+
301
+ async def write_admin_consent(g: Graph, actual: dict | None, after: dict) -> str:
302
+ """Reviewers: the group(s) the baseline names (value consent_reviewers_group); without one, the reviewers
303
+ already set in the tenant stay. A reviewer that is not a group (user, role) keeps its query as read."""
304
+ p = await g.get(CONSENT_PATH)
305
+ if after.get("Reviewers"):
306
+ reviewers = [{"query": name, "queryType": "MicrosoftGraph"} if name.startswith("/")
307
+ else group_query(await g.group_id(name)) for name in after["Reviewers"]]
308
+ else:
309
+ reviewers = p.get("reviewers") or []
310
+ if after.get("IsEnabled") and not reviewers:
311
+ raise WriteError("The admin consent workflow needs at least one reviewer. Set the reviewer group in the "
312
+ "assignment (value consent_reviewers_group) or set reviewers once in the Entra admin center "
313
+ "(Enterprise applications -> Consent and permissions -> Admin consent settings).")
314
+ body = {"isEnabled": after.get("IsEnabled"), "notifyReviewers": after.get("NotifyReviewers"),
315
+ "remindersEnabled": after.get("RemindersEnabled"),
316
+ "requestDurationInDays": after.get("RequestDurationInDays") or p.get("requestDurationInDays") or 30,
317
+ "reviewers": reviewers}
318
+ await g.send("PUT", CONSENT_PATH, body)
319
+ return "adminConsentRequestPolicy"
320
+
321
+
322
+ # ------------------------------------------------------------------ Microsoft 365 group settings
323
+
324
+ GROUP_DEFAULTS = {"EnableGroupCreation": True, "AllowGuestsToAccessGroups": True, "AllowGuestsToBeGroupOwner": False,
325
+ "AllowToAddGuests": True, "EnableMIPLabels": False}
326
+ GROUPS_FIELDS = frozenset({"IsSingleInstance", *GROUP_DEFAULTS, "GroupCreationAllowedGroupName",
327
+ "GuestUsageGuidelinesUrl", "UsageGuidelinesUrl"})
328
+
329
+
330
+ def _setting(v: str | None):
331
+ return {"true": True, "false": False}.get((v or "").lower(), v or None)
332
+
333
+
334
+ async def _unified(g: Graph) -> dict | None:
335
+ return next((x for x in await g.all("/groupSettings") if x.get("displayName") == "Group.Unified"), None)
336
+
337
+
338
+ async def read_groups_settings(g: Graph) -> list[dict]:
339
+ """Group.Unified settings; without a settings object the tenant runs on Microsoft's defaults."""
340
+ row = await _unified(g)
341
+ values = {v["name"]: v.get("value") for v in (row or {}).get("values") or []}
342
+ out = {"IsSingleInstance": "Yes", **GROUP_DEFAULTS}
343
+ for name in GROUP_DEFAULTS:
344
+ if name in values:
345
+ out[name] = _setting(values[name])
346
+ gid = values.get("GroupCreationAllowedGroupId")
347
+ out["GroupCreationAllowedGroupName"] = await g.group_name(gid) if gid else None
348
+ out["GuestUsageGuidelinesUrl"] = values.get("GuestUsageGuidelinesUrl") or None
349
+ out["UsageGuidelinesUrl"] = values.get("UsageGuidelinesUrl") or None
350
+ return [out]
351
+
352
+
353
+ async def write_groups_settings(g: Graph, actual: dict | None, after: dict) -> str:
354
+ diff = changed(actual, after)
355
+ new: dict[str, str] = {}
356
+ for k, v in diff.items():
357
+ if k in GROUP_DEFAULTS:
358
+ new[k] = "true" if v else "false"
359
+ elif k == "GroupCreationAllowedGroupName":
360
+ new["GroupCreationAllowedGroupId"] = await g.group_id(v) if v else ""
361
+ elif k in ("GuestUsageGuidelinesUrl", "UsageGuidelinesUrl"):
362
+ new[k] = v or ""
363
+ row = await _unified(g)
364
+ if row:
365
+ values = [{"name": v["name"], "value": new.pop(v["name"], v.get("value"))} for v in row.get("values") or []]
366
+ values += [{"name": k, "value": v} for k, v in new.items()]
367
+ if diff:
368
+ await g.send("PATCH", f"/groupSettings/{row['id']}", {"values": values})
369
+ return row["id"]
370
+ template = next((t for t in await g.all("/groupSettingTemplates") if t.get("displayName") == "Group.Unified"), None)
371
+ if not template:
372
+ raise WriteError("The tenant has no Group.Unified settings template.")
373
+ values = [{"name": v["name"], "value": new.get(v["name"], v.get("defaultValue"))}
374
+ for v in template.get("values") or []]
375
+ return (await g.send("POST", "/groupSettings", {"templateId": template["id"], "values": values})).get("id")
376
+
377
+
378
+ # ------------------------------------------------------------------ group expiration
379
+
380
+ LIFECYCLE_FIELDS = frozenset({"IsSingleInstance", "GroupLifetimeInDays", "ManagedGroupTypes",
381
+ "AlternateNotificationEmails"})
382
+
383
+
384
+ async def read_group_lifecycle(g: Graph) -> list[dict]:
385
+ return [{"IsSingleInstance": "Yes", "Id": p.get("id"), "GroupLifetimeInDays": p.get("groupLifetimeInDays"),
386
+ "ManagedGroupTypes": p.get("managedGroupTypes"),
387
+ "AlternateNotificationEmails": [x for x in (p.get("alternateNotificationEmails") or "").split(";") if x]}
388
+ for p in (await g.all("/groupLifecyclePolicies"))[:1]]
389
+
390
+
391
+ async def _notification_emails(g: Graph) -> str:
392
+ """Microsoft needs an address for groups without owners. Without one in the baseline: the tenant's technical
393
+ contact (organization.technicalNotificationMails)."""
394
+ org = (await g.all("/organization?$select=technicalNotificationMails"))[:1]
395
+ mails = [m for m in ((org[0].get("technicalNotificationMails") if org else None) or []) if m]
396
+ if not mails:
397
+ raise WriteError("Group expiration needs a notification address for groups without owners. Set the technical "
398
+ "contact of the tenant (Microsoft 365 admin center -> Settings -> Org settings -> "
399
+ "Organization profile) or create the policy once in the Entra admin center.")
400
+ return ";".join(mails)
401
+
402
+
403
+ async def write_group_lifecycle(g: Graph, actual: dict | None, after: dict) -> str:
404
+ body = {"groupLifetimeInDays": int(after["GroupLifetimeInDays"]),
405
+ "managedGroupTypes": after.get("ManagedGroupTypes") or "All"}
406
+ # An empty value is refused («Invalid notification email»): send addresses only when the baseline names them;
407
+ # an existing policy keeps its own, a new one gets the tenant's technical contact.
408
+ if after.get("AlternateNotificationEmails"):
409
+ body["alternateNotificationEmails"] = ";".join(after["AlternateNotificationEmails"])
410
+ elif not (actual and actual.get("Id")):
411
+ body["alternateNotificationEmails"] = await _notification_emails(g)
412
+ if actual and actual.get("Id"):
413
+ if changed(actual, after):
414
+ await g.send("PATCH", f"/groupLifecyclePolicies/{actual['Id']}", body)
415
+ return actual["Id"]
416
+ return (await g.send("POST", "/groupLifecyclePolicies", body)).get("id")