Skip to content

Commit 442e61d

Browse files
authored
Merge pull request #31 from doctolib-lab/bk/sync_assignments_and_metadata
support syncing app assignments and metadata
2 parents b229509 + 73907d7 commit 442e61d

2 files changed

Lines changed: 220 additions & 59 deletions

File tree

IntuneUploader/IntuneAppUploader.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ class IntuneAppUploader(IntuneUploaderBase):
147147
"required": False,
148148
"description": "The scope tags to assign to the app. Provide as a list of strings the ids of the scope tags.",
149149
},
150+
"sync_assignments": {
151+
"required": False,
152+
"description": "When True, syncs assignments to exactly match assignment_info even when the app version is already current. Performs a full idempotent replace rather than additive-only. Defaults to False.",
153+
"default": False,
154+
},
155+
"sync_assignments_diff": {
156+
"required": False,
157+
"description": "When True, reads current assignments before syncing and logs what was added or removed. Also skips the POST when assignments are already in sync. Requires an extra API call per run. Defaults to False.",
158+
"default": False,
159+
},
160+
"sync_metadata": {
161+
"required": False,
162+
"description": "When True, syncs description, displayName, and owner to match recipe inputs even when the app version is already current. Defaults to False.",
163+
"default": False,
164+
},
165+
"publishing_state_timeout": {
166+
"required": False,
167+
"description": "Seconds to wait for the app's publishingState to become 'published' before assigning. Defaults to 60.",
168+
"default": 60,
169+
},
150170
}
151171
output_variables = {
152172
"name": {"description": "The name of the app that was uploaded."},
@@ -209,6 +229,10 @@ def main(self):
209229
ignore_current_app = self.env.get("ignore_current_app")
210230
ignore_current_version = self.env.get("ignore_current_version")
211231
lob_app = self.env.get("lob_app")
232+
sync_assignments = self.env.get("sync_assignments")
233+
sync_assignments_diff = self.env.get("sync_assignments_diff")
234+
sync_metadata = self.env.get("sync_metadata")
235+
publishing_state_timeout = self.env.get("publishing_state_timeout") or 60
212236

213237
# Get the access token
214238
self.token = self.obtain_accesstoken(
@@ -382,6 +406,39 @@ def __post_init__(self):
382406
self.output(
383407
f'App {current_app_data["displayName"]} version {current_app_data["primaryBundleVersion"]} is up to date'
384408
)
409+
self.request = current_app_data
410+
411+
if sync_metadata:
412+
metadata_fields = {
413+
"description": (app_description, current_app_data.get("description")),
414+
"displayName": (app_displayname, current_app_data.get("displayName")),
415+
"owner": (app_owner, current_app_data.get("owner")),
416+
}
417+
metadata_patch = {}
418+
for fname, (desired, current) in metadata_fields.items():
419+
desired = desired or ""
420+
if desired != (current or ""):
421+
metadata_patch[fname] = desired
422+
self.output(
423+
f'{current_app_data["displayName"]}: {fname} "{current}" -> "{desired}"'
424+
)
425+
else:
426+
self.output(
427+
f'{current_app_data["displayName"]}: {fname} already up to date ("{current}")'
428+
)
429+
if metadata_patch:
430+
metadata_patch["@odata.type"] = current_app_data.get("@odata.type")
431+
self.makeapirequestPatch(
432+
f'{self.BASE_ENDPOINT}/{current_app_data["id"]}',
433+
self.token,
434+
"",
435+
json.dumps(metadata_patch),
436+
204,
437+
)
438+
439+
if sync_assignments:
440+
self.wait_for_publishing_state(timeout=publishing_state_timeout)
441+
self.sync_app_assignments(app_assignment_info or [], diff=sync_assignments_diff)
385442
return
386443

387444
# If the app does not exist
@@ -395,7 +452,9 @@ def __post_init__(self):
395452
)
396453

397454
# Create the content version
398-
content_version_url = f'{self.BASE_ENDPOINT}/{self.request["id"]}/{str(app_data_dict["@odata.type"]).replace("#", "")}/contentVersions'
455+
app_odata_type_str = str(app_data_dict["@odata.type"]).replace("#", "")
456+
self.app_odata_type_str = app_odata_type_str
457+
content_version_url = f'{self.BASE_ENDPOINT}/{self.request["id"]}/{app_odata_type_str}/contentVersions'
399458
self.content_version_request = self.makeapirequestPost(
400459
content_version_url,
401460
self.token,
@@ -430,15 +489,15 @@ def __post_init__(self):
430489
# Post the app file info
431490
data = json.dumps(content_file)
432491
self.content_file_request = self.makeapirequestPost(
433-
f'{self.BASE_ENDPOINT}/{self.request["id"]}/microsoft.graph.macOSLobApp/contentVersions/{self.content_version_request["id"]}/files',
492+
f'{self.BASE_ENDPOINT}/{self.request["id"]}/{app_odata_type_str}/contentVersions/{self.content_version_request["id"]}/files',
434493
self.token,
435494
"",
436495
data,
437496
201,
438497
)
439498

440499
# Get the content file upload URL
441-
file_content_request_url = f'{self.BASE_ENDPOINT}/{self.request["id"]}/microsoft.graph.macOSLobApp/contentVersions/{self.content_version_request["id"]}/files/{self.content_file_request["id"]}'
500+
file_content_request_url = f'{self.BASE_ENDPOINT}/{self.request["id"]}/{app_odata_type_str}/contentVersions/{self.content_version_request["id"]}/files/{self.content_file_request["id"]}'
442501
file_content_request = self.makeapirequest(
443502
file_content_request_url,
444503
self.token,
@@ -466,7 +525,7 @@ def __post_init__(self):
466525
# Commit the file
467526
data = json.dumps({"fileEncryptionInfo": encryptionInfo})
468527
self.makeapirequestPost(
469-
f'{self.BASE_ENDPOINT}/{self.request["id"]}/microsoft.graph.macOSLobApp/contentVersions/{self.content_version_request["id"]}/files/{self.content_file_request["id"]}/commit',
528+
f'{self.BASE_ENDPOINT}/{self.request["id"]}/{app_odata_type_str}/contentVersions/{self.content_version_request["id"]}/files/{self.content_file_request["id"]}/commit',
470529
self.token,
471530
"",
472531
data,
@@ -478,7 +537,7 @@ def __post_init__(self):
478537

479538
# Patch the app to use the new content version
480539
data = {
481-
"@odata.type": "#microsoft.graph.macOSLobApp",
540+
"@odata.type": app_data_dict["@odata.type"],
482541
"committedContentVersion": self.content_version_request["id"],
483542
}
484543

@@ -494,6 +553,7 @@ def __post_init__(self):
494553
self.update_categories(app_categories, self.request.get("categories"))
495554

496555
if app_assignment_info:
556+
self.wait_for_publishing_state(timeout=publishing_state_timeout)
497557
for assignment in app_assignment_info:
498558
if "exclude" not in assignment:
499559
assignment["exclude"] = False

IntuneUploader/IntuneUploaderLib/IntuneUploaderBase.py

Lines changed: 155 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ def get_file_content_status(self) -> dict:
497497
Returns:
498498
dict: The file content status dictionary.
499499
"""
500-
url = f"{self.BASE_ENDPOINT}/{self.request['id']}/microsoft.graph.macOSLobApp/contentVersions/{self.content_version_request['id']}/files/{self.content_file_request['id']}"
500+
url = f"{self.BASE_ENDPOINT}/{self.request['id']}/{self.app_odata_type_str}/contentVersions/{self.content_version_request['id']}/files/{self.content_file_request['id']}"
501501
return self.makeapirequest(url, self.token)
502502

503503
def delete_app(self) -> None:
@@ -529,6 +529,26 @@ def wait_for_file_upload(self) -> None:
529529
self.delete_app()
530530
raise ProcessorError("Timed out waiting for file upload to complete")
531531

532+
def wait_for_publishing_state(self, timeout: int = 60) -> None:
533+
"""Waits for the app's publishingState to become 'published' before assignment.
534+
535+
Intune rejects /assign with 400 if publishingState is not 'published' (e.g.
536+
still 'processing' after a recent upload). Polls every 10 s up to `timeout` s.
537+
"""
538+
app_id = self.request["id"]
539+
for _ in range(max(1, timeout // 10)):
540+
app = self.makeapirequest(f"{self.BASE_ENDPOINT}/{app_id}", self.token)
541+
state = app.get("publishingState", "")
542+
if state == "published":
543+
return
544+
self.output(f"App publishingState is '{state}', waiting 10s...")
545+
time.sleep(10)
546+
raise ProcessorError(
547+
f"App '{app_id}' is not in a published state. "
548+
"The app may be stuck — delete it from the Intune admin center and re-run this recipe: "
549+
f"https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/0/appId/{app_id}"
550+
)
551+
532552
def wait_for_azure_storage_uri(self) -> None:
533553
"""Waits for an Azure Storage upload URL to be generated.
534554
@@ -627,8 +647,6 @@ def get_current_app(self, displayname: str, version: int, odata_type: str) -> tu
627647
app
628648
for app in matching_apps
629649
if app["displayName"] == displayname
630-
and app.get("primaryBundleVersion") == version
631-
or app.get("buildNumber") == version
632650
and app["@odata.type"] == odata_type
633651
]
634652
result = None
@@ -719,76 +737,51 @@ def assign_app(self, app, assignment_info: dict) -> None:
719737
current_assignment = self.makeapirequest(
720738
f"{self.BASE_ENDPOINT}/{self.request['id']}/assignments", self.token
721739
)
722-
# Get the current group ids
723740
current_group_ids = [
724741
c["target"].get("groupId")
725742
for c in current_assignment["value"]
726743
if c["target"].get("groupId")
727744
]
728-
# Get the current all assignments
729745
current_all_assignment = [
730746
c["target"].get("@odata.type")
731747
for c in current_assignment["value"]
732748
if c["target"]["@odata.type"] != "#microsoft.graph.groupAssignmentTarget"
733749
]
734750

735-
# Convert human readable All Users and All Devices to the odata type
736-
for assignment in assignment_info:
737-
if assignment.get("all_assignment") == "AllUsers":
738-
assignment["all_assignment"] = (
739-
"#microsoft.graph.allLicensedUsersAssignmentTarget"
740-
)
741-
elif assignment.get("all_assignment") == "AllDevices":
742-
assignment["all_assignment"] = (
743-
"#microsoft.graph.allDevicesAssignmentTarget"
744-
)
751+
normalized = self._normalize_assignment_info(assignment_info)
745752

746-
# Check if the group id is not in the current assignments
747753
missing_assignment = [
748-
a
749-
for a in assignment_info
750-
if "group_id" in a and a["group_id"] not in current_group_ids
754+
a for a in normalized if a["group_id"] is not None and a["group_id"] not in current_group_ids
751755
]
752-
# Check if there are missing all assignments
753756
missing_all_assignment = [
754-
a
755-
for a in assignment_info
756-
if "all_assignment" in a
757-
and a["all_assignment"] not in current_all_assignment
757+
a for a in normalized if a["group_id"] is None and a["odata_type"] not in current_all_assignment
758758
]
759759
data = {"mobileAppAssignments": []}
760760

761-
if missing_assignment:
762-
for assignment in missing_assignment:
763-
# Assign the app to the group
764-
if assignment.get("exclude") is True:
765-
odata_type = "#microsoft.graph.exclusionGroupAssignmentTarget"
766-
else:
767-
odata_type = "#microsoft.graph.groupAssignmentTarget"
768-
data["mobileAppAssignments"].append(
769-
{
770-
"@odata.type": "#microsoft.graph.mobileAppAssignment",
771-
"target": {
772-
"@odata.type": odata_type,
773-
"groupId": assignment["group_id"],
774-
},
775-
"intent": assignment["intent"],
776-
"settings": None,
777-
}
778-
)
761+
for a in missing_assignment:
762+
data["mobileAppAssignments"].append(
763+
{
764+
"@odata.type": "#microsoft.graph.mobileAppAssignment",
765+
"target": {
766+
"@odata.type": a["odata_type"],
767+
"groupId": a["group_id"],
768+
},
769+
"intent": a["intent"],
770+
"settings": None,
771+
}
772+
)
779773

780-
if missing_all_assignment:
781-
for assignment in missing_all_assignment:
782-
data["mobileAppAssignments"].append(
783-
{
784-
"@odata.type": "#microsoft.graph.mobileAppAssignment",
785-
"target": {
786-
"@odata.type": assignment["all_assignment"],
787-
},
788-
"intent": assignment["intent"],
789-
"settings": None,
790-
}
791-
)
774+
for a in missing_all_assignment:
775+
data["mobileAppAssignments"].append(
776+
{
777+
"@odata.type": "#microsoft.graph.mobileAppAssignment",
778+
"target": {
779+
"@odata.type": a["odata_type"],
780+
},
781+
"intent": a["intent"],
782+
"settings": None,
783+
}
784+
)
792785

793786
for assignment in current_assignment["value"]:
794787
data["mobileAppAssignments"].append(
@@ -812,6 +805,114 @@ def assign_app(self, app, assignment_info: dict) -> None:
812805
200,
813806
)
814807

808+
_ALL_ASSIGNMENT_MAP = {
809+
"AllUsers": "#microsoft.graph.allLicensedUsersAssignmentTarget",
810+
"AllDevices": "#microsoft.graph.allDevicesAssignmentTarget",
811+
}
812+
813+
def _normalize_assignment_info(self, assignment_info):
814+
"""Normalize recipe-format assignment_info into (odata_type, group_id, intent) dicts."""
815+
result = []
816+
for a in assignment_info:
817+
intent = a.get("intent", "").lower()
818+
if "all_assignment" in a:
819+
odata_type = self._ALL_ASSIGNMENT_MAP.get(
820+
a["all_assignment"], a["all_assignment"]
821+
)
822+
result.append({"odata_type": odata_type, "group_id": None, "intent": intent})
823+
elif "group_id" in a:
824+
if a.get("exclude"):
825+
odata_type = "#microsoft.graph.exclusionGroupAssignmentTarget"
826+
else:
827+
odata_type = "#microsoft.graph.groupAssignmentTarget"
828+
result.append({"odata_type": odata_type, "group_id": a["group_id"], "intent": intent})
829+
return result
830+
831+
def _normalize_current_assignments(self, assignments):
832+
result = []
833+
for a in assignments:
834+
target = a.get("target", {})
835+
result.append({
836+
"odata_type": target.get("@odata.type", ""),
837+
"group_id": target.get("groupId"),
838+
"intent": a.get("intent", "").lower(),
839+
})
840+
return result
841+
842+
_ODATA_TYPE_LABELS = {
843+
"#microsoft.graph.allLicensedUsersAssignmentTarget": "AllUsers",
844+
"#microsoft.graph.allDevicesAssignmentTarget": "AllDevices",
845+
"#microsoft.graph.groupAssignmentTarget": "group",
846+
"#microsoft.graph.exclusionGroupAssignmentTarget": "exclude",
847+
}
848+
849+
def _assignment_label(self, a):
850+
type_label = self._ODATA_TYPE_LABELS.get(a["odata_type"], a["odata_type"])
851+
target = f":{a['group_id']}" if a["group_id"] else ""
852+
return f"{a['intent']}({type_label}{target})"
853+
854+
def sync_app_assignments(self, assignment_info, diff: bool = False) -> None:
855+
"""Syncs app assignments to exactly match assignment_info via a full replace.
856+
857+
When diff=True, reads current assignments first and skips the POST if already
858+
in sync, logging what would be added/removed. Default is False (unconditional
859+
POST, no extra read).
860+
"""
861+
for a in assignment_info:
862+
if "exclude" not in a:
863+
a["exclude"] = False
864+
865+
desired = self._normalize_assignment_info(assignment_info)
866+
display_name = self.request.get("displayName", "")
867+
868+
if diff:
869+
current_raw = self.makeapirequest(
870+
f"{self.BASE_ENDPOINT}/{self.request['id']}/assignments",
871+
self.token,
872+
)
873+
current = self._normalize_current_assignments(current_raw["value"])
874+
875+
def key(a):
876+
return (a["odata_type"], a["group_id"] or "", a["intent"])
877+
878+
current_keys = {key(a): a for a in current}
879+
desired_keys = {key(a): a for a in desired}
880+
881+
if current_keys == desired_keys:
882+
self.output(f"{display_name} assignments already in sync, skipping.")
883+
return
884+
885+
added = [self._assignment_label(a) for k, a in desired_keys.items() if k not in current_keys]
886+
removed = [self._assignment_label(a) for k, a in current_keys.items() if k not in desired_keys]
887+
parts = []
888+
if added:
889+
parts.append(f"added: {', '.join(added)}")
890+
if removed:
891+
parts.append(f"removed: {', '.join(removed)}")
892+
self.output(f"{display_name} syncing assignments — {'; '.join(parts)}")
893+
894+
assignments = []
895+
for a in desired:
896+
target = {"@odata.type": a["odata_type"]}
897+
if a["group_id"]:
898+
target["groupId"] = a["group_id"]
899+
assignments.append({
900+
"@odata.type": "#microsoft.graph.mobileAppAssignment",
901+
"target": target,
902+
"intent": a["intent"],
903+
"settings": None,
904+
})
905+
906+
self.makeapirequestPost(
907+
f"{self.BASE_ENDPOINT}/{self.request['id']}/assign",
908+
self.token,
909+
"",
910+
json.dumps({"mobileAppAssignments": assignments}),
911+
200,
912+
)
913+
if not diff:
914+
self.output(f"{display_name} synced assignments")
915+
815916

816917
if __name__ == "__main__":
817918
PROCESSOR = IntuneUploaderBase()

0 commit comments

Comments
 (0)