diff --git a/README.md b/README.md index 7d08b68..694b3d8 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,27 @@ Write down the Application (client) ID. You will need this value. Under "Certificates & secrets", generate a new client secret. Set the expiration preferably to never. Write down the value of the client secret created now. It will be hidden later on. Under "Api Permissions" add the following delegated permission from the Microsoft Graph API collection -* Calendars.ReadWrite - *Read and write user calendars* -* Calendars.ReadWrite.Shared - *Read and write user and shared calendars* * offline_access - *Maintain access to data you have given it access to* * Users.Read - *Sign in and read user profile* -* email - *View users' email address* + +If `calendar_access` is equal to `ReadWrite` +* Calendars.ReadWrite - *Read and write user calendars* +* Calendars.ReadWrite.Shared - *Read and write user and shared calendars* + +If `calendar_access` is equal to `Read` +* Calendars.Read - *Read user calendars* +* Calendars.Read.Shared - *Read user and shared calendars* + +If `email_access` is equal to `ReadWrite` * Mail.ReadWrite - *Read and write access to user mail* * Mail.ReadWrite.Shared - *Read and write user and shared mail* * Mail.Send - *Send mail as a user* * Mail.Send.Shared - *Send mail on behalf of others* +If `email_access` is equal to `Read` +* Mail.Read - *Read access to user mail* +* Mail.Read.Shared - *Read user and shared mail* + ## Adding to Home Assistant ### Manual installation @@ -107,6 +118,8 @@ Key | Type | Required | Description `calendars` | `list` | `False` | List of calendar config entries `email_sensors` | `list` | `False` | List of email_sensor config entries `query_sensors` | `list` | `False` | List of query_sensor config entries +`calendar_access` | `Disabled`, `Read` or `ReadWrite` | `False` | Determines the access level for calendars. Defaults to `ReadWrite`. +`email_access` | `Disabled`, `Read` or `ReadWrite` | `False` | Determines the access level for email. Defaults to `ReadWrite`. ### email_sensors Key | Type | Required | Description diff --git a/custom_components/o365/__init__.py b/custom_components/o365/__init__.py index f3d490d..97be8f6 100644 --- a/custom_components/o365/__init__.py +++ b/custom_components/o365/__init__.py @@ -16,7 +16,6 @@ AUTH_CALLBACK_PATH, AUTH_CALLBACK_PATH_ALT, TOKEN_BACKEND, - SCOPE, CONF_CALENDARS, DEFAULT_NAME, CONFIGURATOR_LINK_NAME, @@ -29,14 +28,16 @@ CONF_TRACK_NEW, ) -from .utils import validate_permissions +from .utils import ( + validate_permissions, + get_scopes +) _LOGGER = logging.getLogger(__name__) def setup(hass, config): """Set up the O365 platform.""" - validate_permissions() conf = config.get(DOMAIN, {}) CONFIG_SCHEMA(conf) credentials = (conf.get(CONF_CLIENT_ID), conf.get(CONF_CLIENT_SECRET)) @@ -51,10 +52,11 @@ def setup(hass, config): account = Account(credentials, token_backend=TOKEN_BACKEND) is_authenticated = account.is_authenticated - permissions = validate_permissions() + scopes = get_scopes(conf) + permissions = validate_permissions(scopes) if not is_authenticated or not permissions: url, state = account.con.get_authorization_url( - requested_scopes=SCOPE, redirect_uri=callback_url + requested_scopes=scopes, redirect_uri=callback_url ) _LOGGER.info("no token; requesting authorization") callback_view = O365AuthCallbackView( diff --git a/custom_components/o365/calendar.py b/custom_components/o365/calendar.py index aab0348..f363c88 100644 --- a/custom_components/o365/calendar.py +++ b/custom_components/o365/calendar.py @@ -28,6 +28,8 @@ CONF_MAX_RESULTS, CALENDAR_ENTITY_ID_FORMAT, CONF_TRACK_NEW, + CONF_CALENDAR_ACCESS, + FeatureAccess ) from .utils import ( clean_html, @@ -51,6 +53,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if not is_authenticated: return False + conf = config.get(DOMAIN, {}) + if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.Disabled: + return False + calendar_services = CalendarServices(account, track_new, hass) calendar_services.scan_for_calendars(None) @@ -68,22 +74,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(cal) add_devices(devices, True) - hass.services.register( - DOMAIN, "modify_calendar_event", calendar_services.modify_calendar_event - ) - hass.services.register( - DOMAIN, "create_calendar_event", calendar_services.create_calendar_event - ) - hass.services.register( - DOMAIN, "remove_calendar_event", calendar_services.remove_calendar_event - ) - hass.services.register( - DOMAIN, "respond_calendar_event", calendar_services.respond_calendar_event - ) hass.services.register( DOMAIN, "scan_for_calendars", calendar_services.scan_for_calendars ) + if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.ReadWrite: + hass.services.register( + DOMAIN, "modify_calendar_event", calendar_services.modify_calendar_event + ) + hass.services.register( + DOMAIN, "create_calendar_event", calendar_services.create_calendar_event + ) + hass.services.register( + DOMAIN, "remove_calendar_event", calendar_services.remove_calendar_event + ) + hass.services.register( + DOMAIN, "respond_calendar_event", calendar_services.respond_calendar_event + ) + return True @@ -254,15 +262,13 @@ def to_datetime(obj): @staticmethod def get_end_date(obj): if hasattr(obj, "end"): - enddate = obj.end + return obj.end elif hasattr(obj, "duration"): - enddate = obj.start + obj.duration.value + return obj.start + obj.duration.value else: - enddate = obj.start + timedelta(days=1) - - return enddate + return obj.start + timedelta(days=1) class CalendarServices: diff --git a/custom_components/o365/const.py b/custom_components/o365/const.py index 2d16812..ea01018 100644 --- a/custom_components/o365/const.py +++ b/custom_components/o365/const.py @@ -21,6 +21,11 @@ class EventResponse(Enum): Tentative = "tentative" Decline = "decline" +class FeatureAccess(Enum): + Disabled = "disabled" + Read = "read" + ReadWrite = "readwrite" + ATTR_ATTACHMENTS = "attachments" ATTR_ATTENDEES = "attendees" @@ -76,6 +81,10 @@ class EventResponse(Enum): CONF_SUBJECT_CONTAINS = "subject_contains" CONF_SUBJECT_IS = "subject_is" CONF_TRACK_NEW = "track_new_calendar" + +CONF_CALENDAR_ACCESS = "calendar_access" +CONF_EMAIL_ACCESS = "email_access" + CONFIG_BASE_DIR = get_default_config_dir() CONFIGURATOR_DESCRIPTION = ( "To link your O365 account, click the link, login, and authorize:" @@ -91,21 +100,36 @@ class EventResponse(Enum): ICON = "mdi:office" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) DEFAULT_OFFSET = "!!" -SCOPE = [ +BASE_SCOPES = [ "offline_access", "User.Read", +] +CALENDAR_READ_SCOPES = [ + "Calendars.Read", + "Calendars.Read.Shared", +] +CALENDAR_READ_WRITE_SCOPES = [ "Calendars.ReadWrite", "Calendars.ReadWrite.Shared", +] +EMAIL_READ_SCOPES = [ + "Mail.Read", + "Mail.Read.Shared", +] +EMAIL_READ_WRITE_SCOPES = [ "Mail.ReadWrite", "Mail.ReadWrite.Shared", "Mail.Send", "Mail.Send.Shared", ] -MINIMUM_REQUIRED_SCOPES = [ - "User.Read", - "Calendars.ReadWrite", - "Mail.ReadWrite", - "Mail.Send", +# Scopes that might not exist in the retrieved token +IGNORABLE_SCOPES = [ + "offline_access", + "Calendars.Read.Shared", + "Calendars.ReadWrite.Shared", + "Mail.Read.Shared", + "Mail.ReadWrite.Shared", + "Mail.Send.Shared", ] TOKEN_BACKEND = FileSystemTokenBackend( token_path=DEFAULT_CACHE_PATH, token_filename="o365.token" @@ -151,6 +175,9 @@ class EventResponse(Enum): vol.Optional(CONF_CALENDARS, default=[]): [CALENDAR_SCHEMA], vol.Optional(CONF_EMAIL_SENSORS): [EMAIL_SENSOR], vol.Optional(CONF_QUERY_SENSORS): [QUERY_SENSOR], + + vol.Optional(CONF_CALENDAR_ACCESS, default='ReadWrite'): cv.enum(FeatureAccess), + vol.Optional(CONF_EMAIL_ACCESS, default='ReadWrite'): cv.enum(FeatureAccess) }, ) }, diff --git a/custom_components/o365/notify.py b/custom_components/o365/notify.py index 0299862..f305595 100644 --- a/custom_components/o365/notify.py +++ b/custom_components/o365/notify.py @@ -13,6 +13,8 @@ ATTR_ZIP_ATTACHMENTS, ATTR_ZIP_NAME, NOTIFY_BASE_SCHEMA, + CONF_EMAIL_ACCESS, + FeatureAccess, ) _LOGGER = logging.getLogger(__name__) @@ -25,8 +27,10 @@ async def async_get_service(hass, config, discovery_info=None): is_authenticated = account.is_authenticated if not is_authenticated: return - email_service = O365EmailService(account) - return email_service + conf = config.get(DOMAIN, {}) + if conf.get(CONF_EMAIL_ACCESS) is not FeatureAccess.ReadWrite: + return + return O365EmailService(account) class O365EmailService(BaseNotificationService): diff --git a/custom_components/o365/sensor.py b/custom_components/o365/sensor.py index d2ff659..d012c37 100644 --- a/custom_components/o365/sensor.py +++ b/custom_components/o365/sensor.py @@ -14,6 +14,8 @@ CONF_IS_UNREAD, CONF_EMAIL_SENSORS, CONF_QUERY_SENSORS, + CONF_EMAIL_ACCESS, + FeatureAccess, ) from .utils import get_email_attributes @@ -29,6 +31,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if not is_authenticated: return False + conf = config.get(DOMAIN, {}) + if conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.Disabled: + return False + unread_sensors = hass.data[DOMAIN].get(CONF_EMAIL_SENSORS, []) for conf in unread_sensors: sensor = O365InboxSensor(account, conf) diff --git a/custom_components/o365/utils.py b/custom_components/o365/utils.py index e68bd29..6e22f92 100644 --- a/custom_components/o365/utils.py +++ b/custom_components/o365/utils.py @@ -6,7 +6,12 @@ from bs4 import BeautifulSoup from .const import ( DEFAULT_CACHE_PATH, - MINIMUM_REQUIRED_SCOPES, + BASE_SCOPES, + CALENDAR_READ_SCOPES, + CALENDAR_READ_WRITE_SCOPES, + EMAIL_READ_SCOPES, + EMAIL_READ_WRITE_SCOPES, + IGNORABLE_SCOPES, CONFIG_BASE_DIR, DATETIME_FORMAT, CALENDAR_DEVICE_SCHEMA, @@ -15,6 +20,9 @@ CONF_TRACK, CONF_NAME, CONF_DEVICE_ID, + CONF_CALENDAR_ACCESS, + CONF_EMAIL_ACCESS, + FeatureAccess, ) from O365.calendar import Attendee from homeassistant.util import dt @@ -34,17 +42,34 @@ def clean_html(html): else: return html +def get_scopes(conf): + scopes = [x for x in BASE_SCOPES] -def validate_permissions(token_path=DEFAULT_CACHE_PATH, token_filename="o365.token"): + if conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.ReadWrite: + scopes += CALENDAR_READ_WRITE_SCOPES + elif conf.get(CONF_CALENDAR_ACCESS) is FeatureAccess.Read: + scopes += CALENDAR_READ_SCOPES + + if conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.ReadWrite: + scopes += EMAIL_READ_WRITE_SCOPES + elif conf.get(CONF_EMAIL_ACCESS) is FeatureAccess.Read: + scopes += EMAIL_READ_SCOPES + + _LOGGER.warning(f"Required scopes: {scopes}") + + return scopes + + +def validate_permissions(scopes, token_path=DEFAULT_CACHE_PATH, token_filename="o365.token"): full_token_path = os.path.join(token_path, token_filename) if not os.path.exists(full_token_path) or not os.path.isfile(full_token_path): - _LOGGER.warning(f"Could not loacte token at {full_token_path}") + _LOGGER.warning(f"Could not locate token at {full_token_path}") return False with open(full_token_path, "r", encoding="UTF-8") as fh: raw = fh.read() permissions = json.loads(raw)["scope"] - scope = [x for x in MINIMUM_REQUIRED_SCOPES] - all_permissions_granted = all([x in permissions for x in scope]) + mandatory_scopes = [x for x in scopes if x not in IGNORABLE_SCOPES] + all_permissions_granted = all(x in permissions for x in mandatory_scopes) if not all_permissions_granted: _LOGGER.warning(f"All permissions granted: {all_permissions_granted}") return all_permissions_granted @@ -191,7 +216,7 @@ def load_calendars(path): def get_calendar_info(hass, calendar, track_new_devices): """Convert data from O365 into DEVICE_SCHEMA.""" - calendar_info = CALENDAR_DEVICE_SCHEMA( + return CALENDAR_DEVICE_SCHEMA( { CONF_CAL_ID: calendar.calendar_id, CONF_ENTITIES: [ @@ -203,7 +228,6 @@ def get_calendar_info(hass, calendar, track_new_devices): ], } ) - return calendar_info def update_calendar_file(path, calendar, hass, track_new_devices):