Source code for sni.uac.clearance

"""
Clearance management and verification. Each user has a **clearance level**,
which is an integer:

* Level -1: User is a guest and has no priviledge whatsoever.

* Level 0: User only have access to public data and read-write access to its
  own ESI.

* Level 1: User has access to read-only ESI (i.e. ``GET`` calls) of all members
  of his corporation.

* Level 2: User has access to read-write ESI (i.e. all http methods) of all
  members of his corporation.

* Level 3: User has access to read-only ESI (i.e. ``GET`` calls) of all members
  of his alliance.

* Level 4: User has access to read-write ESI (i.e. all http methods) of all
  members of his alliance.

* Level 5: User has access to read-only ESI (i.e. ``GET`` calls) of all members
  of his coalition.

* Level 6: User has access to read-write ESI (i.e. all http methods) of all
  members of his coalition.

* Level 7: User has access to read-only ESI (i.e. ``GET`` calls) of all members
  regisered on this SNI instance.

* Level 8: User has access to read-write ESI (i.e. all http methods) of all
  members regisered on this SNI instance.

* Level 9: User has clearance level 8 and some administrative priviledges.

* Level 10: Superuser.

Furthermore, note that

* Any user can raise any other to its own clearance level, provided they are in
  the same corporation (for levels 1 and 2), alliance (for levels 3 and 4), or
  coalition (for levels 5 and 6).

* Demoting users is considered an administrative task and requires a clearance
  of at least 9.

* Clearance levels are public informations.

See :meth:`sni.uac.clearance.has_clearance` for a precise specification of how
clearance levels are checked, and :const:`sni.uac.clearance.SCOPE_LEVELS` for
the declaration of all scopes.
"""

from dataclasses import dataclass
import logging
from typing import Dict, Optional

from sni.esi.scope import EsiScope
from sni.db.cache import cache_get, cache_set
from sni.user.models import Coalition, User


[docs]class AbstractScope: """ Represents an abstract scope. In SNI, a scope is a class that determines wether a *source* user is authorized to perform an action against a *target* user (see :meth:`sni.uac.clearance.AbstractScope.has_clearance`). """
[docs] def has_clearance(self, source: User, target: Optional[User]) -> bool: """ Pure virtual methode, raises a ``NotImplementedError``. """ raise NotImplementedError
[docs]@dataclass class AbsoluteScope(AbstractScope): """ An absolute scope asserts that the source user has at least a pretermined clearance level. """ level: int
[docs] def has_clearance(self, source: User, target: Optional[User]) -> bool: if target is not None: logging.warning("Absolute scopes should not have targets") return self.level <= source.clearance_level
[docs]@dataclass class ClearanceModificationScope(AbstractScope): """ This scope determines when a user is authorized to change the clearance level of another user. See :meth:`sni.uac.clearance.ClearanceModificationScope.has_clearance`. """ level: int
[docs] def has_clearance(self, source: User, target: Optional[User]) -> bool: """ For the source user to change the clearance level of the target user at the level indicated in :data:`sni.uac.clearance.ClearanceModificationScope.level`, the clearance level of the source must be hierarchically superior to the target, and have read-write access to the target's corporation, alliance, or coalition, whichever is most global. In addition, the source must have at least the same clearance level than the target. """ if target is None: logging.warning("Clearance modification scopes must have a target") return False if source == target: # Cannot change own clearance return False return source.clearance_level >= max( [ distance_penalty(source, target) + 1, self.level, target.clearance_level, ] )
[docs]@dataclass class ESIScope(AbstractScope): """ This scope determines when a user is authorized to read or write ESI data. """ level: int
[docs] def has_clearance(self, source: User, target: Optional[User]) -> bool: if target is None: logging.warning("ESI scopes must have a target") return False if source.clearance_level >= 7: return True return ( source.clearance_level >= distance_penalty(source, target) + self.level )
SCOPES: Dict[str, AbstractScope] = { EsiScope.ESI_ALLIANCES_READ_CONTACTS_V1: ESIScope(0), EsiScope.ESI_ASSETS_READ_ASSETS_V1: ESIScope(0), EsiScope.ESI_ASSETS_READ_CORPORATION_ASSETS_V1: ESIScope(0), EsiScope.ESI_BOOKMARKS_READ_CHARACTER_BOOKMARKS_V1: ESIScope(0), EsiScope.ESI_BOOKMARKS_READ_CORPORATION_BOOKMARKS_V1: ESIScope(0), EsiScope.ESI_CALENDAR_READ_CALENDAR_EVENTS_V1: ESIScope(0), EsiScope.ESI_CALENDAR_RESPOND_CALENDAR_EVENTS_V1: ESIScope(1), EsiScope.ESI_CHARACTERS_READ_AGENTS_RESEARCH_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_BLUEPRINTS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_CHAT_CHANNELS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_CONTACTS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_CORPORATION_ROLES_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_FATIGUE_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_FW_STATS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_LOYALTY_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_MEDALS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_NOTIFICATIONS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_OPPORTUNITIES_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_STANDINGS_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_READ_TITLES_V1: ESIScope(0), EsiScope.ESI_CHARACTERS_WRITE_CONTACTS_V1: ESIScope(1), EsiScope.ESI_CHARACTERSTATS_READ_V1: ESIScope(0), EsiScope.ESI_CLONES_READ_CLONES_V1: ESIScope(0), EsiScope.ESI_CLONES_READ_IMPLANTS_V1: ESIScope(0), EsiScope.ESI_CONTRACTS_READ_CHARACTER_CONTRACTS_V1: ESIScope(0), EsiScope.ESI_CONTRACTS_READ_CORPORATION_CONTRACTS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_BLUEPRINTS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_CONTACTS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_CONTAINER_LOGS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_CORPORATION_MEMBERSHIP_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_DIVISIONS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_FACILITIES_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_FW_STATS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_MEDALS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_STANDINGS_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_STARBASES_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_STRUCTURES_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_READ_TITLES_V1: ESIScope(0), EsiScope.ESI_CORPORATIONS_TRACK_MEMBERS_V1: ESIScope(0), EsiScope.ESI_FITTINGS_READ_FITTINGS_V1: ESIScope(0), EsiScope.ESI_FITTINGS_WRITE_FITTINGS_V1: ESIScope(1), EsiScope.ESI_FLEETS_READ_FLEET_V1: ESIScope(0), EsiScope.ESI_FLEETS_WRITE_FLEET_V1: ESIScope(1), EsiScope.ESI_INDUSTRY_READ_CHARACTER_JOBS_V1: ESIScope(0), EsiScope.ESI_INDUSTRY_READ_CHARACTER_MINING_V1: ESIScope(0), EsiScope.ESI_INDUSTRY_READ_CORPORATION_JOBS_V1: ESIScope(0), EsiScope.ESI_INDUSTRY_READ_CORPORATION_MINING_V1: ESIScope(0), EsiScope.ESI_KILLMAILS_READ_CORPORATION_KILLMAILS_V1: ESIScope(0), EsiScope.ESI_KILLMAILS_READ_KILLMAILS_V1: ESIScope(0), EsiScope.ESI_LOCATION_READ_LOCATION_V1: ESIScope(0), EsiScope.ESI_LOCATION_READ_ONLINE_V1: ESIScope(0), EsiScope.ESI_LOCATION_READ_SHIP_TYPE_V1: ESIScope(0), EsiScope.ESI_MAIL_ORGANIZE_MAIL_V1: ESIScope(1), EsiScope.ESI_MAIL_READ_MAIL_V1: ESIScope(0), EsiScope.ESI_MAIL_SEND_MAIL_V1: ESIScope(1), EsiScope.ESI_MARKETS_READ_CHARACTER_ORDERS_V1: ESIScope(0), EsiScope.ESI_MARKETS_READ_CORPORATION_ORDERS_V1: ESIScope(0), EsiScope.ESI_MARKETS_STRUCTURE_MARKETS_V1: ESIScope(1), EsiScope.ESI_PLANETS_MANAGE_PLANETS_V1: ESIScope(1), EsiScope.ESI_PLANETS_READ_CUSTOMS_OFFICES_V1: ESIScope(0), EsiScope.ESI_SEARCH_SEARCH_STRUCTURES_V1: ESIScope(0), EsiScope.ESI_SKILLS_READ_SKILLQUEUE_V1: ESIScope(0), EsiScope.ESI_SKILLS_READ_SKILLS_V1: ESIScope(0), EsiScope.ESI_UI_OPEN_WINDOW_V1: ESIScope(1), EsiScope.ESI_UI_WRITE_WAYPOINT_V1: ESIScope(1), EsiScope.ESI_UNIVERSE_READ_STRUCTURES_V1: ESIScope(0), EsiScope.ESI_WALLET_READ_CHARACTER_WALLET_V1: ESIScope(0), EsiScope.ESI_WALLET_READ_CORPORATION_WALLET_V1: ESIScope(0), EsiScope.ESI_WALLET_READ_CORPORATION_WALLETS_V1: ESIScope(0), EsiScope.PUBLICDATA: AbsoluteScope(0), "sni.create_coalition": AbsoluteScope(9), "sni.track_coalition": AbsoluteScope(9), "sni.create_dyn_token": AbsoluteScope(10), "sni.create_group": AbsoluteScope(9), "sni.create_per_token": AbsoluteScope(10), "sni.create_use_token": AbsoluteScope(0), "sni.create_user": AbsoluteScope(9), "sni.delete_coalition": AbsoluteScope(9), "sni.delete_crash_report": AbsoluteScope(10), "sni.delete_dyn_token": AbsoluteScope(10), "sni.delete_group": AbsoluteScope(9), "sni.delete_per_token": AbsoluteScope(10), "sni.delete_use_token": AbsoluteScope(10), "sni.delete_user": AbsoluteScope(9), "sni.discord.auth": AbsoluteScope(0), "sni.discord.read_user": AbsoluteScope(0), "sni.read_coalition": AbsoluteScope(0), "sni.read_crash_report": AbsoluteScope(10), "sni.read_dyn_token": AbsoluteScope(9), "sni.read_group": AbsoluteScope(0), "sni.read_own_token": AbsoluteScope(0), "sni.read_per_token": AbsoluteScope(9), "sni.read_use_token": AbsoluteScope(0), "sni.read_user": AbsoluteScope(0), "sni.set_authorized_to_login": AbsoluteScope(9), "sni.set_clearance_level_0": ClearanceModificationScope(0), "sni.set_clearance_level_1": ClearanceModificationScope(1), "sni.set_clearance_level_10": ClearanceModificationScope(10), "sni.set_clearance_level_2": ClearanceModificationScope(2), "sni.set_clearance_level_3": ClearanceModificationScope(3), "sni.set_clearance_level_4": ClearanceModificationScope(4), "sni.set_clearance_level_5": ClearanceModificationScope(5), "sni.set_clearance_level_6": ClearanceModificationScope(6), "sni.set_clearance_level_7": ClearanceModificationScope(7), "sni.set_clearance_level_8": ClearanceModificationScope(8), "sni.set_clearance_level_9": ClearanceModificationScope(9), "sni.teamspeak.auth": AbsoluteScope(0), "sni.teamspeak.read_user": AbsoluteScope(0), "sni.teamspeak.update_group_mapping": AbsoluteScope(9), "sni.update_coalition": AbsoluteScope(9), "sni.update_dyn_token": AbsoluteScope(10), "sni.update_group": AbsoluteScope(9), "sni.update_per_token": AbsoluteScope(10), "sni.update_use_token": AbsoluteScope(0), "sni.update_user": AbsoluteScope(9), "sni.system.read_configuration": AbsoluteScope(10), "sni.system.read_jobs": AbsoluteScope(10), "sni.system.submit_job": AbsoluteScope(10), "sni.fetch_corporation": AbsoluteScope(8), "sni.track_corporation": ESIScope(0), "sni.create_corporation_guest": ESIScope(0), "sni.delete_corporation_guest": ESIScope(1), "sni.read_corporation_guests": ESIScope(0), "sni.read_corporation": AbsoluteScope(0), "sni.update_corporation": ESIScope(1), "sni.fetch_alliance": AbsoluteScope(8), "sni.track_alliance": ESIScope(0), "sni.read_alliance": AbsoluteScope(0), "sni.update_alliance": ESIScope(1), }
[docs]def are_in_same_alliance(user1: User, user2: User) -> bool: """ Tells wether two users are in the same alliance. Users that have no alliance are considered in a different alliance as everyone else. """ if ( user1.corporation is None or user1.corporation.alliance is None or user2.corporation is None or user2.corporation.alliance is None ): return False return user1.corporation.alliance == user2.corporation.alliance
[docs]def are_in_same_coalition(user1: User, user2: User) -> bool: """ Tells wether two users have a coalition in common. """ if user1.corporation is None or user1.corporation.alliance is None: return False if user2.corporation is None or user2.corporation.alliance is None: return False for coalition in Coalition.objects( member_alliances=user1.corporation.alliance ): if user2.corporation.alliance in coalition.member_alliances: return True return False
[docs]def are_in_same_corporation(user1: User, user2: User) -> bool: """ Tells wether two users are in the same corporation. Users that have no corporation are considered in different a corporation as everyone else. """ if user1.corporation is None or user2.corporation is None: return False return user1.corporation == user2.corporation
[docs]def assert_has_clearance( source: User, scope: str, target: Optional[User] = None ) -> None: """ Like :meth:`sni.uac.clearance.has_clearance` but raises a :class:`PermissionError` if the result is ``False``. """ if not has_clearance(source, scope, target): raise PermissionError
[docs]def distance_penalty(source: User, target: User) -> int: """ Returns 0 if both users are the same user; returns 1 if they are not the same but in the same corporation; 3 if they are not in the same corporation but in the same alliance; 5 if they are not in the same alliance but in the same coalition; and otherwise, returns 7. """ if source == target: return 0 if are_in_same_corporation(source, target): return 1 if are_in_same_alliance(source, target): return 3 if are_in_same_coalition(source, target): return 5 return 7
[docs]def has_clearance( source: User, scope_name: str, target: Optional[User] = None ) -> bool: """ Check wether the *source* user has sufficient clearance to perform a given action (or *scope*) against the *target* user. """ scope = SCOPES.get(scope_name) if scope is None: logging.warning('Unknown scope "%s"', scope_name) return False cache_key = ( "clr", [ source.character_id, scope_name, target.character_id if target is not None else None, ], ) result = cache_get(cache_key) if not isinstance(result, bool): result = scope.has_clearance(source, target) cache_set(cache_key, result) logging.debug( "Access %s --[%s]--> %s %s", source.character_name, scope_name, target.character_name if target is not None else "N/A", "granted" if result else "denied", ) return result
[docs]def reset_clearance(usr: User, save: bool = False): """ Resets a user's clearance. If a user is the CEO of its corporation, then a clearance level of 2 is granted. If its corporation is the executor of the alliance, then a level of 4 is granted instead. If the user is root or has a clearance level of 10, then a level of 10 is applied (so that superusers are preserved no matter what). Otherwise, the user's clearance level is set to 0. """ if usr.clearance_level >= 9: return if usr.character_id == 0: usr.clearance_level = 10 elif usr.is_ceo_of_alliance(): usr.clearance_level = 4 elif usr.is_ceo_of_corporation(): usr.clearance_level = 2 elif usr.clearance_level >= 0: usr.clearance_level = 0 if save: logging.debug( "Reset clearance level of %s to %d", usr.character_name, usr.clearance_level, ) usr.save()