Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/command_modules/azure-cli-role/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Release History
===============

2.0.19
2.0.20
++++++
* Minor fixes.
* role assignments: expose "role assignment list-changelogs" for rbac audit

2.0.18
++++++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ def load_arguments(self, _):
c.argument('ids', nargs='+', help='space-separated role assignment ids')
c.argument('include_classic_administrators', arg_type=get_three_state_flag(), help='list default role assignments for subscription classic administrators, aka co-admins')

time_help = ('The {} of the query in the format of %Y-%m-%dT%H:%M:%SZ, e.g. 2000-12-31T12:59:59Z. Defaults to {}')
with self.argument_context('role assignment list-changelogs') as c:
c.argument('start_time', help=time_help.format('start time', '1 Hour prior to the current time'))
c.argument('end_time', help=time_help.format('end time', 'the current time'))

with self.argument_context('role definition') as c:
c.argument('role_definition_id', options_list=['--name', '-n'], help='the role definition name')
c.argument('custom_role_only', arg_type=get_three_state_flag(), help='custom roles only(vs. build-in ones)')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def load_command_table(self, _):
g.custom_command('delete', 'delete_role_assignments')
g.custom_command('list', 'list_role_assignments', table_transformer=transform_assignment_list)
g.custom_command('create', 'create_role_assignment')
g.custom_command('list-changelogs', 'list_role_assignment_change_logs')

with self.command_group('ad app', client_factory=get_graph_client_applications, resource_type=PROFILE_TYPE, min_api='2017-03-10') as g:
g.custom_command('create', 'create_application')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import print_function

import datetime
import json
import re
import os
import uuid
Expand Down Expand Up @@ -207,6 +208,138 @@ def list_role_assignments(cmd, assignee=None, role=None, resource_group_name=Non
return results


def _get_assignment_events(cli_ctx, start_time=None, end_time=None):
from azure.mgmt.monitor import MonitorManagementClient
from azure.cli.core.commands.client_factory import get_mgmt_service_client
client = get_mgmt_service_client(cli_ctx, MonitorManagementClient)
DATE_TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
if end_time:
try:
end_time = datetime.datetime.strptime(end_time, DATE_TIME_FORMAT)
except ValueError:
raise CLIError("Input '{}' is not valid datetime. Valid example: 2000-12-31T12:59:59Z".format(end_time))
else:
end_time = datetime.datetime.utcnow()

if start_time:
try:
start_time = datetime.datetime.strptime(start_time, DATE_TIME_FORMAT)
if start_time >= end_time:
raise CLIError("Start time cannot be later than end time.")
except ValueError:
raise CLIError("Input '{}' is not valid datetime. Valid example: 2000-12-31T12:59:59Z".format(start_time))
else:
start_time = end_time - datetime.timedelta(hours=1)

time_filter = 'eventTimestamp ge {} and eventTimestamp le {}'.format(start_time.strftime('%Y-%m-%dT%H:%M:%SZ'),
end_time.strftime('%Y-%m-%dT%H:%M:%SZ'))

# set time range filter
odata_filters = 'resourceProvider eq Microsoft.Authorization and {}'.format(time_filter)

activity_log = list(client.activity_logs.list(filter=odata_filters))
start_events, end_events, offline_events = {}, {}, []

for l in activity_log:
if l.http_request:
if l.status.value == 'Started':
start_events[l.operation_id] = l
else:
end_events[l.operation_id] = l
elif l.event_name and l.event_name.value.lower() == 'classicadministrators':
offline_events.append(l)
return start_events, end_events, offline_events, client


# A custom command around 'monitoring' events to produce understandable output for RBAC audit, a common scenario.
def list_role_assignment_change_logs(cmd, start_time=None, end_time=None):
# pylint: disable=too-many-nested-blocks, too-many-statements
result = []
start_events, end_events, offline_events, client = _get_assignment_events(cmd.cli_ctx, start_time, end_time)
role_defs = {d.id: [d.properties.role_name, d.id.split('/')[-1]] for d in list_role_definitions(cmd)}

for op_id in start_events:
e = end_events.get(op_id, None)
if not e:
continue

entry = {}
op = e.operation_name and e.operation_name.value
if (op.lower().startswith('microsoft.authorization/roleassignments') and e.status.value == 'Succeeded'):
s, payload = start_events[op_id], None
entry = dict.fromkeys(
['principalId', 'principalName', 'scope', 'scopeName', 'scopeType', 'roleDefinitionId', 'roleName'],
None)
entry['timestamp'], entry['caller'] = e.event_timestamp, s.caller

if s.http_request:
if s.http_request.method == 'PUT':
# 'requestbody' has a wrong camel-case. Should be 'requestBody'
payload = s.properties and s.properties.get('requestbody')
entry['action'] = 'Granted'
entry['scope'] = e.authorization.scope
elif s.http_request.method == 'DELETE':
payload = e.properties and e.properties.get('responseBody')
entry['action'] = 'Revoked'
if payload:
try:
payload = json.loads(payload)
except ValueError:
pass
if payload:
payload = payload['properties']
entry['principalId'] = payload['principalId']
if not entry['scope']:
entry['scope'] = payload['scope']
if entry['scope']:
index = entry['scope'].lower().find('/providers/microsoft.authorization')
if index != -1:
entry['scope'] = entry['scope'][:index]
parts = list(filter(None, entry['scope'].split('/')))
entry['scopeName'] = parts[-1]
if len(parts) < 3:
entry['scopeType'] = 'Subscription'
elif len(parts) < 5:
entry['scopeType'] = 'Resource group'
else:
entry['scopeType'] = 'Resource'

entry['roleDefinitionId'] = role_defs[payload['roleDefinitionId']][1]
entry['roleName'] = role_defs[payload['roleDefinitionId']][0]
result.append(entry)

# Fill in logical user/sp names as guid principal-id not readable
principal_ids = [x['principalId'] for x in result if x['principalId']]
if principal_ids:
graph_client = _graph_client_factory(cmd.cli_ctx)
stubs = _get_object_stubs(graph_client, principal_ids)
principal_dics = {i.object_id: _get_displayable_name(i) for i in stubs}
if principal_dics:
for e in result:
e['principalName'] = principal_dics.get(e['principalId'], None)

offline_events = [x for x in offline_events if (x.status and x.status.value == 'Succeeded' and x.operation_name and
x.operation_name.value.lower().startswith(
'microsoft.authorization/classicadministrators'))]
for e in offline_events:
entry = {
'timestamp': e.event_timestamp,
'caller': 'Subscription Admin',
'roleDefinitionId': None,
'principalId': None,
'principalType': 'User',
'scope': '/subscriptions/' + client.config.subscription_id,
'scopeType': 'Subscription',
'scopeName': client.config.subscription_id,
}
if e.properties:
entry['principalName'] = e.properties.get('adminEmail')
entry['roleName'] = e.properties.get('adminType')
result.append(entry)

return result


def _backfill_assignments_for_co_admins(cli_ctx, auth_client, assignee=None):
co_admins = auth_client.classic_administrators.list('2015-06-01') # known swagger bug on api-version handling
co_admins = [x for x in co_admins if x.properties.email_address]
Expand Down Expand Up @@ -721,7 +854,6 @@ def create_service_principal_for_rbac(
raise

if show_auth_for_sdk:
import json
from azure.cli.core._profile import Profile
profile = Profile(cli_ctx=cmd.cli_ctx)
result = profile.get_sp_auth_info(scopes[0].split('/')[2] if scopes else None,
Expand Down
Loading