# Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. from collections import namedtuple from threading import Lock from ._authorizer import ClosedAuthorizer from ._identity import NoIdentities from ._error import ( AuthInitError, VerificationError, IdentityError, DischargeRequiredError, PermissionDenied, ) import macaroonbakery.checkers as checkers import pyrfc3339 class Op(namedtuple('Op', 'entity, action')): ''' Op holds an entity and action to be authorized on that entity. entity string holds the name of the entity to be authorized. @param entity should not contain spaces and should not start with the prefix "login" or "multi-" (conventionally, entity names will be prefixed with the entity type followed by a hyphen. @param action string holds the action to perform on the entity, such as "read" or "delete". It is up to the service using a checker to define a set of operations and keep them consistent over time. ''' # LOGIN_OP represents a login (authentication) operation. # A macaroon that is associated with this operation generally # carries authentication information with it. LOGIN_OP = Op(entity='login', action='login') class Checker(object): '''Checker implements an authentication and authorization checker. It uses macaroons as authorization tokens but it is not itself responsible for creating the macaroons See the Oven type (TODO) for one way of doing that. ''' def __init__(self, checker=checkers.Checker(), authorizer=ClosedAuthorizer(), identity_client=None, macaroon_opstore=None): ''' :param checker: a first party checker implementing a :param authorizer (Authorizer): used to check whether an authenticated user is allowed to perform operations. The identity parameter passed to authorizer.allow will always have been obtained from a call to identity_client.declared_identity. :param identity_client (IdentityClient) used for interactions with the external identity service used for authentication. If this is None, no authentication will be possible. :param macaroon_opstore (object with new_macaroon and macaroon_ops method): used to retrieve macaroon root keys and other associated information. ''' self._first_party_caveat_checker = checker self._authorizer = authorizer if identity_client is None: identity_client = NoIdentities() self._identity_client = identity_client self._macaroon_opstore = macaroon_opstore def auth(self, mss): ''' Returns a new AuthChecker instance using the given macaroons to inform authorization decisions. @param mss: a list of macaroon lists. ''' return AuthChecker(parent=self, macaroons=mss) def namespace(self): ''' Returns the namespace of the first party checker. ''' return self._first_party_caveat_checker.namespace() class AuthChecker(object): '''Authorizes operations with respect to a user's request. The identity is authenticated only once, the first time any method of the AuthChecker is called, using the context passed in then. To find out any declared identity without requiring a login, use allow(ctx); to require authentication but no additional operations, use allow(ctx, LOGIN_OP). ''' def __init__(self, parent, macaroons): ''' :param parent (Checker): used to check first party caveats. :param macaroons: a list of py macaroons ''' self._macaroons = macaroons self._init_errors = [] self._executed = False self._identity = None self._identity_caveats = [] self.parent = parent self._conditions = None self._mutex = Lock() def _init(self, ctx): with self._mutex: if not self._executed: self._init_once(ctx) self._executed = True def _init_once(self, ctx): self._auth_indexes = {} self._conditions = [None] * len(self._macaroons) for i, ms in enumerate(self._macaroons): try: ops, conditions = self.parent._macaroon_opstore.macaroon_ops(ms) except VerificationError as e: self._init_errors.append(str(e)) continue except Exception as exc: raise AuthInitError(str(exc)) # It's a valid macaroon (in principle - we haven't checked first # party caveats). self._conditions[i] = conditions is_login = False for op in ops: if op == LOGIN_OP: # Don't associate the macaroon with the login operation # until we've verified that it is valid below is_login = True else: if op not in self._auth_indexes: self._auth_indexes[op] = [] self._auth_indexes[op].append(i) if not is_login: continue # It's a login macaroon. Check the conditions now - # all calls want to see the same authentication # information so that callers have a consistent idea of # the client's identity. # # If the conditions fail, we won't use the macaroon for # identity, but we can still potentially use it for its # other operations if the conditions succeed for those. declared, err = self._check_conditions(ctx, LOGIN_OP, conditions) if err is not None: self._init_errors.append('cannot authorize login macaroon: ' + err) continue if self._identity is not None: # We've already found a login macaroon so ignore this one # for the purposes of identity. continue try: identity = self.parent._identity_client.declared_identity( ctx, declared) except IdentityError as exc: self._init_errors.append( 'cannot decode declared identity: {}'.format(exc.args[0])) continue if LOGIN_OP not in self._auth_indexes: self._auth_indexes[LOGIN_OP] = [] self._auth_indexes[LOGIN_OP].append(i) self._identity = identity if self._identity is None: # No identity yet, so try to get one based on the context. try: identity, cavs = self.parent.\ _identity_client.identity_from_context(ctx) except IdentityError: self._init_errors.append('could not determine identity') if cavs is None: cavs = [] self._identity, self._identity_caveats = identity, cavs return None def allow(self, ctx, ops): ''' Checks that the authorizer's request is authorized to perform all the given operations. Note that allow does not check first party caveats - if there is more than one macaroon that may authorize the request, it will choose the first one that does regardless. If all the operations are allowed, an AuthInfo is returned holding details of the decision and any first party caveats that must be checked before actually executing any operation. If operations include LOGIN_OP, the request should contain an authentication macaroon proving the client's identity. Once an authentication macaroon is chosen, it will be used for all other authorization requests. If an operation was not allowed, an exception will be raised which may be: - DischargeRequiredError holding the operations that remain to be authorized in order to allow authorization to proceed - PermissionDenied when no operations can be authorized and there's no third party to discharge macaroons for. @param ctx AuthContext @param ops an array of Op :return: an AuthInfo object. ''' auth_info, _ = self.allow_any(ctx, ops) return auth_info def allow_any(self, ctx, ops): ''' like allow except that it will authorize as many of the operations as possible without requiring any to be authorized. If all the operations succeeded, the array will be nil. If any the operations failed, the returned error will be the same that allow would return and each element in the returned slice will hold whether its respective operation was allowed. If all the operations succeeded, the returned slice will be None. The returned AuthInfo will always be non-None. The LOGIN_OP operation is treated specially - it is always required if present in ops. @param ctx AuthContext @param ops an array of Op :return: an AuthInfo object and the auth used as an array of int. ''' authed, used = self._allow_any(ctx, ops) return self._new_auth_info(used), authed def _new_auth_info(self, used): info = AuthInfo(identity=self._identity, macaroons=[]) for i, is_used in enumerate(used): if is_used: info.macaroons.append(self._macaroons[i]) return info def _allow_any(self, ctx, ops): self._init(ctx) used = [False] * len(self._macaroons) authed = [False] * len(ops) num_authed = 0 errors = [] for i, op in enumerate(ops): for mindex in self._auth_indexes.get(op, []): _, err = self._check_conditions(ctx, op, self._conditions[mindex]) if err is not None: errors.append(err) continue authed[i] = True num_authed += 1 used[mindex] = True # Use the first authorized macaroon only. break if op == LOGIN_OP and not authed[i] and self._identity is not None: # Allow LOGIN_OP when there's an authenticated user even # when there's no macaroon that specifically authorizes it. authed[i] = True if self._identity is not None: # We've authenticated as a user, so even if the operations didn't # specifically require it, we add the login macaroon # to the macaroons used. # Note that the LOGIN_OP conditions have already been checked # successfully in initOnceFunc so no need to check again. # Note also that there may not be any macaroons if the # identity client decided on an identity even with no # macaroons. for i in self._auth_indexes.get(LOGIN_OP, []): used[i] = True if num_authed == len(ops): # All operations allowed. return authed, used # There are some unauthorized operations. need = [] need_index = [0] * (len(ops) - num_authed) for i, ok in enumerate(authed): if not ok: need_index[len(need)] = i need.append(ops[i]) # Try to authorize the operations # even if we haven't got an authenticated user. oks, caveats = self.parent._authorizer.authorize( ctx, self._identity, need) still_need = [] for i, _ in enumerate(need): if i < len(oks) and oks[i]: authed[need_index[i]] = True else: still_need.append(ops[need_index[i]]) if len(still_need) == 0 and len(caveats) == 0: # No more ops need to be authenticated and # no caveats to be discharged. return authed, used if self._identity is None and len(self._identity_caveats) > 0: raise DischargeRequiredError( msg='authentication required', ops=[LOGIN_OP], cavs=self._identity_caveats) if caveats is None or len(caveats) == 0: all_errors = [] all_errors.extend(self._init_errors) all_errors.extend(errors) err = '' if len(all_errors) > 0: err = all_errors[0] raise PermissionDenied(err) raise DischargeRequiredError( msg='some operations have extra caveats', ops=ops, cavs=caveats) def allow_capability(self, ctx, ops): '''Checks that the user is allowed to perform all the given operations. If not, a discharge error will be raised. If allow_capability succeeds, it returns a list of first party caveat conditions that must be applied to any macaroon granting capability to execute the operations. Those caveat conditions will not include any declarations contained in login macaroons - the caller must be careful not to mint a macaroon associated with the LOGIN_OP operation unless they add the expected declaration caveat too - in general, clients should not create capabilities that grant LOGIN_OP rights. The operations must include at least one non-LOGIN_OP operation. ''' nops = 0 for op in ops: if op != LOGIN_OP: nops += 1 if nops == 0: raise ValueError('no non-login operations required in capability') _, used = self._allow_any(ctx, ops) squasher = _CaveatSquasher() for i, is_used in enumerate(used): if not is_used: continue for cond in self._conditions[i]: squasher.add(cond) return squasher.final() def _check_conditions(self, ctx, op, conds): declared = checkers.infer_declared_from_conditions( conds, self.parent.namespace()) ctx = checkers.context_with_operations(ctx, [op.action]) ctx = checkers.context_with_declared(ctx, declared) for cond in conds: err = self.parent._first_party_caveat_checker.\ check_first_party_caveat(ctx, cond) if err is not None: return None, err return declared, None class AuthInfo(namedtuple('AuthInfo', 'identity macaroons')): '''AuthInfo information about an authorization decision. @param identity: holds information on the authenticated user as returned identity_client. It may be None after a successful authorization if LOGIN_OP access was not required. @param macaroons: holds all the macaroons that were used for the authorization. Macaroons that were invalid or unnecessary are not included. ''' class _CaveatSquasher(object): ''' Rationalizes first party caveats created for a capability by: - including only the earliest time-before caveat. - excluding allow and deny caveats (operations are checked by virtue of the operations associated with the macaroon). - removing declared caveats. - removing duplicates. ''' def __init__(self, expiry=None, conds=None): self._expiry = expiry if conds is None: conds = [] self._conds = conds def add(self, cond): if self._add(cond): self._conds.append(cond) def _add(self, cond): try: cond, args = checkers.parse_caveat(cond) except ValueError: # Be safe - if we can't parse the caveat, just leave it there. return True if cond == checkers.COND_TIME_BEFORE: try: et = pyrfc3339.parse(args, utc=True).replace(tzinfo=None) except ValueError: # Again, if it doesn't seem valid, leave it alone. return True if self._expiry is None or et <= self._expiry: self._expiry = et return False elif cond in [checkers.COND_ALLOW, checkers.COND_DENY, checkers.COND_DECLARED]: return False return True def final(self): if self._expiry is not None: self._conds.append( checkers.time_before_caveat(self._expiry).condition) # Make deterministic and eliminate duplicates. return sorted(set(self._conds))