def get_info(self, key: str) -> Optional[TokenInfo]: """Return information about a token. Parameters ---------- key : `str` The key of the token. Returns ------- info : `gafaelfawr.models.token.TokenInfo` or `None` Information about that token or `None` if it doesn't exist in the database. Notes ----- There is probably some way to materialize parent as a relationship field on the ORM Token objects, but that gets into gnarly and hard-to-understand SQLAlchemy ORM internals. This approach still does only one database query without fancy ORM mappings at the cost of some irritating mangling of the return value. """ result = ( self._session.query(SQLToken, Subtoken.parent) .filter_by(token=key) .join(Subtoken, Subtoken.child == SQLToken.token, isouter=True) .one_or_none() ) if result: info = TokenInfo.from_orm(result[0]) info.parent = result[1] return info else: return None
def modify( self, key: str, *, token_name: Optional[str] = None, scopes: Optional[List[str]] = None, expires: Optional[datetime] = None, no_expire: bool = False, ) -> Optional[TokenInfo]: """Modify a token. Parameters ---------- token : `str` The token to modify. token_name : `str`, optional The new name for the token. scopes : List[`str`], optional The new scopes for the token. expires : `datetime`, optional The new expiration time for the token. no_expire : `bool` If set, the token should not expire. This is a separate parameter because passing `None` to ``expires`` is ambiguous. Returns ------- info : `gafaelfawr.models.token.TokenInfo` Information for the updated token. Raises ------ gafaelfawr.exceptions.DuplicateTokenNameError The user already has a token by that name. """ token = self._session.query(SQLToken).filter_by(token=key).scalar() if not token: return None if token_name: name_conflict = ( self._session.query(SQLToken.token) .filter_by(username=token.username, token_name=token_name) .scalar() ) if name_conflict: msg = f"Token name {token_name} already used" raise DuplicateTokenNameError(msg) token.token_name = token_name if scopes: token.scopes = ",".join(sorted(scopes)) if no_expire: token.expires = None elif expires: token.expires = expires return TokenInfo.from_orm(token)
def list(self, *, username: Optional[str] = None) -> List[TokenInfo]: """List tokens. Parameters ---------- username : `str` or `None` Limit the returned tokens to ones for the given username. Returns ------- tokens : List[`gafaelfawr.models.token.TokenInfo`] Information about the tokens. """ if username: tokens = ( self._session.query(SQLToken) .filter_by(username=username) .order_by(SQLToken.token) ) else: tokens = self._session.query(SQLToken).order_by(SQLToken.token) return [TokenInfo.from_orm(t) for t in tokens]
async def test_modify(factory: ComponentFactory) -> None: user_info = TokenUserInfo(username="******", name="Example Person", uid=4137) token_service = factory.create_token_service() async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["read:all", "user:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data async with factory.session.begin(): user_token = await token_service.create_user_token( data, data.username, token_name="some-token", scopes=[], ip_address="127.0.0.1", ) expires = current_datetime() + timedelta(days=50) async with factory.session.begin(): await token_service.modify_token( user_token.key, data, token_name="happy token", ip_address="127.0.0.1", ) await token_service.modify_token( user_token.key, data, scopes=["read:all"], expires=expires, ip_address="192.168.0.4", ) info = await token_service.get_token_info_unchecked(user_token.key) assert info and info == TokenInfo( token=user_token.key, username="******", token_type=TokenType.user, token_name="happy token", scopes=["read:all"], created=info.created, expires=expires, last_used=None, parent=None, ) async with factory.session.begin(): await token_service.modify_token( user_token.key, data, expires=None, no_expire=True, ip_address="127.0.4.5", ) async with factory.session.begin(): history = await token_service.get_change_history( data, token=user_token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=user_token.key, username=data.username, token_type=TokenType.user, token_name="happy token", scopes=["read:all"], expires=None, actor=data.username, action=TokenChange.edit, old_expires=expires, ip_address="127.0.4.5", event_time=history.entries[0].event_time, ), TokenChangeHistoryEntry( token=user_token.key, username=data.username, token_type=TokenType.user, token_name="happy token", scopes=["read:all"], expires=expires, actor=data.username, action=TokenChange.edit, old_scopes=[], old_expires=None, ip_address="192.168.0.4", event_time=history.entries[1].event_time, ), TokenChangeHistoryEntry( token=user_token.key, username=data.username, token_type=TokenType.user, token_name="happy token", scopes=[], expires=None, actor=data.username, action=TokenChange.edit, old_token_name="some-token", ip_address="127.0.0.1", event_time=history.entries[2].event_time, ), TokenChangeHistoryEntry( token=user_token.key, username=data.username, token_type=TokenType.user, token_name="some-token", scopes=[], expires=None, actor=data.username, action=TokenChange.create, ip_address="127.0.0.1", event_time=info.created, ), ]
async def test_session_token(config: Config, factory: ComponentFactory) -> None: token_service = factory.create_token_service() user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[ TokenGroup(name="group", id=1000), TokenGroup(name="another", id=3134), ], ) async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=["user:token"], ip_address="127.0.0.1") data = await token_service.get_data(token) assert data and data == TokenData( token=token, username="******", token_type=TokenType.session, scopes=["user:token"], created=data.created, expires=data.expires, name="Example Person", uid=4137, groups=[ TokenGroup(name="group", id=1000), TokenGroup(name="another", id=3134), ], ) assert_is_now(data.created) expires = data.created + timedelta(minutes=config.issuer.exp_minutes) assert data.expires == expires async with factory.session.begin(): info = await token_service.get_token_info_unchecked(token.key) assert info and info == TokenInfo( token=token.key, username=user_info.username, token_name=None, token_type=TokenType.session, scopes=data.scopes, created=int(data.created.timestamp()), last_used=None, expires=int(data.expires.timestamp()), parent=None, ) assert await token_service.get_user_info(token) == user_info async with factory.session.begin(): history = await token_service.get_change_history( data, token=token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.session, scopes=["user:token"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="127.0.0.1", event_time=data.created, ) ] # Test a session token with scopes. async with factory.session.begin(): token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin"], ip_address="127.0.0.1", ) data = await token_service.get_data(token) assert data and data.scopes == ["exec:admin", "read:all"] info = await token_service.get_token_info_unchecked(token.key) assert info and info.scopes == ["exec:admin", "read:all"]
async def test_internal_token(config: Config, factory: ComponentFactory) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) token_service = factory.create_token_service() async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data async with factory.session.begin(): internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="2001:db8::45", ) assert await token_service.get_user_info(internal_token) == user_info info = await token_service.get_token_info_unchecked(internal_token.key) assert info and info == TokenInfo( token=internal_token.key, username=user_info.username, token_type=TokenType.internal, service="some-service", scopes=["read:all"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) # Cannot request a scope that the parent token doesn't have. with pytest.raises(InvalidScopesError): await token_service.get_internal_token( data, service="some-service", scopes=["read:some"], ip_address="127.0.0.1", ) # Creating another internal token from the same parent token with the same # parameters just returns the same internal token as before. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token # Try again with the cache cleared to force a database lookup. await token_service._token_cache.clear() async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token async with factory.session.begin(): history = await token_service.get_change_history( data, token=internal_token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=internal_token.key, username=data.username, token_type=TokenType.internal, parent=data.token.key, service="some-service", scopes=["read:all"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="2001:db8::45", event_time=info.created, ) ] # It's possible we'll have a race condition where two workers both create # an internal token at the same time with the same parameters. Gafaelfawr # 3.0.2 had a regression where, once that had happened, it could not # retrieve the internal token because it didn't expect multiple results # from the query. Simulate this and make sure it's handled properly. The # easiest way to do this is to use the internals of the token service. second_internal_token = Token() created = current_datetime() expires = created + config.token_lifetime internal_token_data = TokenData( token=second_internal_token, username=data.username, token_type=TokenType.internal, scopes=["read:all"], created=created, expires=expires, name=data.name, email=data.email, uid=data.uid, groups=data.groups, ) await token_service._token_redis_store.store_data(internal_token_data) async with factory.session.begin(): await token_service._token_db_store.add(internal_token_data, service="some-service", parent=data.token.key) await token_service._token_cache.clear() async with factory.session.begin(): dup_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert dup_internal_token in (internal_token, second_internal_token) # A different scope or a different service results in a new token. async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["exec:admin"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="another-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. Use this to # test a token with empty scopes. async with factory.session.begin(): user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=["exec:admin"], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data async with factory.session.begin(): new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=[], ip_address="127.0.0.1") assert new_internal_token != internal_token info = await token_service.get_token_info_unchecked( new_internal_token.key) assert info and info.scopes == [] expires = info.created + timedelta(minutes=config.issuer.exp_minutes) assert info.expires == expires
async def test_user_token(factory: ComponentFactory) -> None: user_info = TokenUserInfo(username="******", name="Example Person", uid=4137) token_service = factory.create_token_service() async with factory.session.begin(): session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data expires = current_datetime() + timedelta(days=2) # Scopes are provided not in sorted order to ensure they're sorted when # creating the token. async with factory.session.begin(): user_token = await token_service.create_user_token( data, "example", token_name="some-token", scopes=["read:all", "exec:admin"], expires=expires, ip_address="192.168.0.1", ) assert await token_service.get_user_info(user_token) == user_info info = await token_service.get_token_info_unchecked(user_token.key) assert info and info == TokenInfo( token=user_token.key, username=user_info.username, token_name="some-token", token_type=TokenType.user, scopes=["exec:admin", "read:all"], created=info.created, last_used=None, expires=int(expires.timestamp()), parent=None, ) assert_is_now(info.created) assert await token_service.get_data(user_token) == TokenData( token=user_token, username=user_info.username, token_type=TokenType.user, scopes=["exec:admin", "read:all"], created=info.created, expires=info.expires, name=user_info.name, uid=user_info.uid, ) async with factory.session.begin(): history = await token_service.get_change_history( data, token=user_token.key, username=data.username) assert history.entries == [ TokenChangeHistoryEntry( token=user_token.key, username=data.username, token_type=TokenType.user, token_name="some-token", scopes=["exec:admin", "read:all"], expires=info.expires, actor=data.username, action=TokenChange.create, ip_address="192.168.0.1", event_time=info.created, ) ]
async def test_internal_token(setup: SetupTest) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) token_service = setup.factory.create_token_service() session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="2001:db8::45", ) assert await token_service.get_user_info(internal_token) == user_info info = token_service.get_token_info_unchecked(internal_token.key) assert info and info == TokenInfo( token=internal_token.key, username=user_info.username, token_type=TokenType.internal, service="some-service", scopes=["read:all"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) # Cannot request a scope that the parent token doesn't have. with pytest.raises(InvalidScopesError): await token_service.get_internal_token( data, service="some-service", scopes=["read:some"], ip_address="127.0.0.1", ) # Creating another internal token from the same parent token with the same # parameters just returns the same internal token as before. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token == new_internal_token history = token_service.get_change_history( data, token=internal_token.key, username=data.username ) assert history.entries == [ TokenChangeHistoryEntry( token=internal_token.key, username=data.username, token_type=TokenType.internal, parent=data.token.key, service="some-service", scopes=["read:all"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="2001:db8::45", event_time=info.created, ) ] # A different scope or a different service results in a new token. new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=["exec:admin"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token new_internal_token = await token_service.get_internal_token( data, service="another-service", scopes=["read:all"], ip_address="127.0.0.1", ) assert internal_token != new_internal_token # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. Use this to # test a token with empty scopes. user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=["exec:admin"], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data new_internal_token = await token_service.get_internal_token( data, service="some-service", scopes=[], ip_address="127.0.0.1" ) assert new_internal_token != internal_token info = token_service.get_token_info_unchecked(new_internal_token.key) assert info and info.scopes == [] expires = info.created + timedelta(minutes=setup.config.issuer.exp_minutes) assert info.expires == expires
async def test_notebook_token(setup: SetupTest) -> None: user_info = TokenUserInfo( username="******", name="Example Person", uid=4137, groups=[TokenGroup(name="foo", id=1000)], ) token_service = setup.factory.create_token_service() session_token = await token_service.create_session_token( user_info, scopes=["read:all", "exec:admin", "user:token"], ip_address="127.0.0.1", ) data = await token_service.get_data(session_token) assert data token = await token_service.get_notebook_token(data, ip_address="1.0.0.1") assert await token_service.get_user_info(token) == user_info info = token_service.get_token_info_unchecked(token.key) assert info and info == TokenInfo( token=token.key, username=user_info.username, token_type=TokenType.notebook, scopes=["exec:admin", "read:all", "user:token"], created=info.created, last_used=None, expires=data.expires, parent=session_token.key, ) assert_is_now(info.created) assert await token_service.get_data(token) == TokenData( token=token, username=user_info.username, token_type=TokenType.notebook, scopes=["exec:admin", "read:all", "user:token"], created=info.created, expires=data.expires, name=user_info.name, uid=user_info.uid, groups=user_info.groups, ) # Creating another notebook token from the same parent token just returns # the same notebook token as before. new_token = await token_service.get_notebook_token( data, ip_address="127.0.0.1" ) assert token == new_token history = token_service.get_change_history( data, token=token.key, username=data.username ) assert history.entries == [ TokenChangeHistoryEntry( token=token.key, username=data.username, token_type=TokenType.notebook, parent=data.token.key, scopes=["exec:admin", "read:all", "user:token"], expires=data.expires, actor=data.username, action=TokenChange.create, ip_address="1.0.0.1", event_time=info.created, ) ] # Check that the expiration time is capped by creating a user token that # doesn't expire and then creating a notebook token from it. user_token = await token_service.create_user_token( data, data.username, token_name="some token", scopes=[], expires=None, ip_address="127.0.0.1", ) data = await token_service.get_data(user_token) assert data new_token = await token_service.get_notebook_token( data, ip_address="127.0.0.1" ) assert new_token != token info = token_service.get_token_info_unchecked(new_token.key) assert info expires = info.created + timedelta(minutes=setup.config.issuer.exp_minutes) assert info.expires == expires