Skip to content

Commit 5cac441

Browse files
authored
Merge pull request #727 from uw-it-aca/task/access-reconcile
reconcile existing
2 parents 37eef1a + eb1fd99 commit 5cac441

File tree

10 files changed

+333
-60
lines changed

10 files changed

+333
-60
lines changed

.github/workflows/cicd.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ on:
1414

1515
jobs:
1616
context:
17-
runs-on: ubuntu-20.04
17+
runs-on: ubuntu-24.04
1818

1919
outputs:
2020
commit_hash: ${{ steps.context.outputs.commit_hash }}
@@ -29,7 +29,7 @@ jobs:
2929
release_name: ${{ env.RELEASE_NAME }}
3030

3131
build:
32-
runs-on: ubuntu-20.04
32+
runs-on: ubuntu-24.04
3333

3434
needs: context
3535

@@ -40,7 +40,7 @@ jobs:
4040
- name: Set up Python
4141
uses: actions/setup-python@v5
4242
with:
43-
python-version: 3.6
43+
python-version: 3.8
4444

4545
- name: Run Python Linters
4646
uses: uw-it-aca/actions/python-linters@main
@@ -109,7 +109,7 @@ jobs:
109109

110110
needs: [context, build]
111111

112-
runs-on: ubuntu-20.04
112+
runs-on: ubuntu-24.04
113113

114114
outputs:
115115
context: ${{ steps.context.outputs.context }}
@@ -141,7 +141,7 @@ jobs:
141141

142142
needs: [context, build, deploy]
143143

144-
runs-on: ubuntu-20.04
144+
runs-on: ubuntu-24.04
145145

146146
steps:
147147
- name: House Keeping

endorsement/management/commands/reconcile_shared_drives.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Copyright 2025 UW-IT, University of Washington
22
# SPDX-License-Identifier: Apache-2.0
33

4-
# Copyright 2024 UW-IT, University of Washington
5-
# SPDX-License-Identifier: Apache-2.0
64
import logging
75
import sys
86

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright 2025 UW-IT, University of Washington
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from django.core.management.base import BaseCommand
5+
from endorsement.models import AccessRecord, AccessRight, AccessRecordConflict
6+
from endorsement.dao.access import (
7+
get_accessee_model, store_access_record, set_delegate)
8+
from endorsement.dao.office import get_office_accessor
9+
from endorsement.exceptions import UnrecognizedUWNetid, UnrecognizedGroupID
10+
from uw_msca.delegate import get_all_delegates, _msca_get_delegate_url
11+
from uw_msca import get_resource
12+
import json
13+
import csv
14+
import logging
15+
16+
17+
logger = logging.getLogger(__name__)
18+
logger.setLevel(logging.INFO)
19+
20+
21+
class Command(BaseCommand):
22+
help = "Restore Office365 mailbox access from MSCA"
23+
24+
def add_arguments(self, parser):
25+
parser.add_argument(
26+
'--commit',
27+
action='store_true',
28+
default=False,
29+
help='Store access record changes',
30+
)
31+
parser.add_argument(
32+
'--netid',
33+
type=str,
34+
help='Netid to restore rights for')
35+
parser.add_argument(
36+
'--csv',
37+
type=str,
38+
help='CSV of accessor netid, accessor name, access right')
39+
40+
def handle(self, *args, **options):
41+
self.commit_changes = options['commit']
42+
netid = options['netid']
43+
csv_file = options['csv']
44+
try:
45+
if netid:
46+
self.restore_netid_access(netid)
47+
elif csv_file:
48+
self.restore_csv_access(csv_file)
49+
except Exception as ex:
50+
logger.error("restore_access_rights: Exception: {}".format(ex))
51+
52+
def restore_netid_access(self, netid):
53+
accessee = get_accessee_model(netid)
54+
for delegate, right in self.get_delegates_for_netid(netid).items():
55+
try:
56+
self.fix_access_record(accessee, delegate, right)
57+
except Exception as ex:
58+
logger.info(f"ERROR: assign delegate {delegate}: {ex}")
59+
continue
60+
61+
def get_delegates_for_netid(self, netid):
62+
url = _msca_get_delegate_url(netid)
63+
response = get_resource(url)
64+
json_response = json.loads(response)
65+
delegates = {}
66+
for delegation in json_response:
67+
if netid != delegation['netid']:
68+
raise Exception("netid mismatch")
69+
netid = delegation['netid']
70+
delegations = delegation['delegates']
71+
if isinstance(delegations, dict):
72+
user = delegations['User']
73+
if not user:
74+
continue
75+
76+
rights = delegations['AccessRights']
77+
delegates[user] = rights
78+
elif isinstance(delegations, list):
79+
for d in delegations:
80+
user = d['User']
81+
if not user:
82+
continue
83+
84+
rights = d['AccessRights']
85+
if isinstance(rights, list):
86+
if len(rights) == 1:
87+
delegates[user] = rights[0]
88+
else:
89+
raise Exception(f"multiple rights for "
90+
f"{user}: {rights}")
91+
elif isinstance(rights, str):
92+
delegates[user] = rights
93+
else:
94+
raise Exception(f"unknown right type for {user}")
95+
return delegates
96+
97+
def fix_access_record(self, accessee, delegate, right):
98+
try:
99+
accessor = get_office_accessor(delegate)
100+
except Exception as ex:
101+
raise Exception(f"ERROR: get accessor {delegate}: {ex}")
102+
103+
try:
104+
ar = AccessRecord.objects.get(
105+
accessee=accessee, accessor=accessor)
106+
except AccessRecord.DoesNotExist:
107+
raise Exception(f"ERROR: no record: mailbox {netid} "
108+
f"delegate {delegate}")
109+
try:
110+
rr = AccessRight.objects.get(name=right)
111+
except AccessRight.DoesNotExist:
112+
raise Exception(f"ERROR: unknown right {right} ")
113+
114+
if ar.access_right != rr:
115+
if self.commit_changes:
116+
ar.access_right = rr
117+
ar.save()
118+
119+
logger.info(
120+
f"{'' if self.commit_changes else 'WOULD '}ASSIGN: "
121+
f"mailbox {ar.accessee.netid} "
122+
f"delegate {ar.accessor.name} "
123+
f"right '{ar.access_right.display_name if (
124+
self.commit_changes) else rr.display_name}'")
125+
126+
def reconcile_csv_access(self, csv_file):
127+
delegations = {}
128+
with open(csv_file, 'r') as f:
129+
blank_reader = csv.reader(f)
130+
for i, line in enumerate(blank_reader):
131+
delegates = json.loads(line[1])
132+
if not delegates or delegates == 'null':
133+
continue
134+
135+
netid = line[0]
136+
if netid not in delegations:
137+
delegations[netid] = {}
138+
139+
if isinstance(delegates, dict):
140+
user = delegates['User']
141+
right = delegates['AccessRights']
142+
delegations[netid][user] = right
143+
else:
144+
for d in delegates:
145+
user = d['User']
146+
right = d['AccessRights']
147+
delegations[netid][user] = right
148+
149+
with open('/tmp/blanks_remaining.csv', 'r') as f:
150+
blank_reader = csv.reader(f)
151+
for i, line in enumerate(blank_reader):
152+
netid = line[0]
153+
if netid not in delegations:
154+
logger.info(f"mailbox {netid} has no delegates")
155+
continue
156+
157+
try:
158+
accessee = get_accessee_model(netid)
159+
except UnrecognizedUWNetid:
160+
logger.info(f"ERROR: get accessee {netid}: unrecognized")
161+
continue
162+
except Exception as ex:
163+
logger.info(f"ERROR: get accessee {netid}: {ex}")
164+
continue
165+
166+
for delegate, right in delegations[netid].items():
167+
try:
168+
fix_access_record(accessee, delegate, right)
169+
except Exception as ex:
170+
logger.info(f"ERROR: assign delegate {delegate}: {ex}")
171+
continue

endorsement/notifications/access.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ def _create_accessee_expiration_notice(notice_level, access, policy):
8484
}
8585

8686
if notice_level < 4:
87-
subject = ("Action Required: Office 365 Shared Mailbox "
88-
"service will expire soon")
87+
subject = ("Action Required: Office 365 mailbox "
88+
"permissions expiring soon")
8989
text_template = _email_template("notice_warning.txt")
9090
html_template = _email_template("notice_warning.html")
9191
else:
@@ -104,7 +104,11 @@ def accessee_lifecycle_warning(notice_level):
104104

105105
for drive in drives:
106106
try:
107-
email = [uw_email_address(drive.accessee.netid)]
107+
owner = get_owner_for_shared_netid(drive.accessee.netid)
108+
if not owner:
109+
owner = drive.accessee.netid
110+
111+
email = [uw_email_address(owner)]
108112
(subject,
109113
text_body,
110114
html_body) = _create_accessee_expiration_notice(

0 commit comments

Comments
 (0)