diff --git a/releasenotes/notes/add-default-project-id-825e195531344394.yaml b/releasenotes/notes/add-default-project-id-825e195531344394.yaml new file mode 100644 index 0000000..b3dc5a7 --- /dev/null +++ b/releasenotes/notes/add-default-project-id-825e195531344394.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add ``default_project_id`` into profile to return. + - | + If user has default_project_id, then we will login into this project as default. diff --git a/skyline_apiserver/api/v1/login.py b/skyline_apiserver/api/v1/login.py index c64f108..ea6447b 100644 --- a/skyline_apiserver/api/v1/login.py +++ b/skyline_apiserver/api/v1/login.py @@ -15,7 +15,7 @@ from __future__ import annotations from pathlib import PurePath -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse @@ -26,7 +26,7 @@ from keystoneclient.client import Client as KeystoneClient from skyline_apiserver import schemas from skyline_apiserver.api import deps from skyline_apiserver.client import utils -from skyline_apiserver.client.openstack.keystone import revoke_token +from skyline_apiserver.client.openstack.keystone import get_token_data, get_user, revoke_token from skyline_apiserver.client.openstack.system import ( get_endpoints, get_project_scope_token, @@ -46,6 +46,19 @@ from skyline_apiserver.types import constants router = APIRouter() +async def _get_default_project_id( + session: Session, region: str, user_id: Optional[str] = None +) -> Union[str, None]: + if not user_id: + token = session.get_token() + token_data = await get_token_data(token, region, session) + _user_id = token_data["token"]["user"]["id"] + else: + _user_id = user_id + user = await get_user(_user_id, region, session) + return getattr(user, "default_project_id", None) + + async def _get_projects_and_unscope_token( region: str, domain: Optional[str] = None, @@ -53,7 +66,7 @@ async def _get_projects_and_unscope_token( password: Optional[str] = None, token: Optional[str] = None, project_enabled: bool = False, -) -> Tuple[List[Any], str]: +) -> Tuple[List[Any], str, Union[str, None]]: auth_url = await utils.get_endpoint( region=region, service="keystone", @@ -78,6 +91,9 @@ async def _get_projects_and_unscope_token( session = Session( auth=unscope_auth, verify=CONF.default.cafile, timeout=constants.DEFAULT_TIMEOUT ) + + default_project_id = await _get_default_project_id(session, region) + unscope_client = KeystoneClient( session=session, endpoint=auth_url, @@ -93,7 +109,7 @@ async def _get_projects_and_unscope_token( if not project_scope: raise Exception("You are not authorized for any projects or domains.") - return project_scope, unscope_token + return project_scope, unscope_token, default_project_id async def _patch_profile(profile: schemas.Profile, global_request_id: str) -> schemas.Profile: @@ -107,9 +123,13 @@ async def _patch_profile(profile: schemas.Profile, global_request_id: str) -> sc ) if not projects: - projects, _ = await _get_projects_and_unscope_token( + projects, _, default_project_id = await _get_projects_and_unscope_token( region=profile.region, token=profile.keystone_token ) + else: + default_project_id = await _get_default_project_id( + get_system_session(), profile.region, user_id=profile.user.id + ) profile.projects = { i.id: { @@ -121,6 +141,8 @@ async def _patch_profile(profile: schemas.Profile, global_request_id: str) -> sc for i in projects } + profile.default_project_id = default_project_id + except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -151,7 +173,7 @@ async def login( ), ) -> schemas.Profile: try: - project_scope, unscope_token = await _get_projects_and_unscope_token( + project_scope, unscope_token, default_project_id = await _get_projects_and_unscope_token( region=credential.region, domain=credential.domain, username=credential.username, @@ -162,7 +184,7 @@ async def login( project_scope_token = await get_project_scope_token( keystone_token=unscope_token, region=credential.region, - project_id=project_scope[0].id, + project_id=default_project_id or project_scope[0].id, ) profile = await generate_profile( @@ -248,7 +270,7 @@ async def websso( ), ) -> RedirectResponse: try: - project_scope, _ = await _get_projects_and_unscope_token( + project_scope, _, default_project_id = await _get_projects_and_unscope_token( region=CONF.openstack.sso_region, token=token, project_enabled=True, @@ -257,7 +279,7 @@ async def websso( project_scope_token = await get_project_scope_token( keystone_token=token, region=CONF.openstack.sso_region, - project_id=project_scope[0].id, + project_id=default_project_id or project_scope[0].id, ) profile = await generate_profile( diff --git a/skyline_apiserver/client/openstack/keystone.py b/skyline_apiserver/client/openstack/keystone.py index ef3da49..5ccc03e 100644 --- a/skyline_apiserver/client/openstack/keystone.py +++ b/skyline_apiserver/client/openstack/keystone.py @@ -17,7 +17,7 @@ from __future__ import annotations from typing import Any, Dict, Optional from fastapi import HTTPException, status -from keystoneauth1.exceptions.http import Unauthorized +from keystoneauth1.exceptions.http import NotFound, Unauthorized from keystoneauth1.session import Session from starlette.concurrency import run_in_threadpool @@ -82,3 +82,43 @@ async def revoke_token( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e), ) + + +async def get_token_data(token: str, region: str, session: Session) -> Any: + try: + kc = await utils.keystone_client( + session=session, + region=region, + ) + return await run_in_threadpool(kc.tokens.get_token_data, token) + except Unauthorized as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + +async def get_user(id: str, region: str, session: Session) -> Any: + try: + kc = await utils.keystone_client(session=session, region=region) + return await run_in_threadpool(kc.users.get, id) + except Unauthorized as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + except NotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) diff --git a/skyline_apiserver/schemas/login.py b/skyline_apiserver/schemas/login.py index 88177ae..1b193a6 100644 --- a/skyline_apiserver/schemas/login.py +++ b/skyline_apiserver/schemas/login.py @@ -94,6 +94,7 @@ class Profile(PayloadBase): base_domains: Optional[List[str]] = Field(None, description="User base domains") endpoints: Optional[Dict[str, Any]] = Field(None, description="Keystone endpoints") projects: Optional[Dict[str, Any]] = Field(None, description="User projects") + default_project_id: Optional[str] = Field(None, description="User default project ID") version: str = Field(..., description="Version") def toPayLoad(self) -> Payload: diff --git a/swagger.json b/swagger.json index 65b6804..de89e87 100644 --- a/swagger.json +++ b/swagger.json @@ -2757,6 +2757,11 @@ "type": "object", "description": "User projects" }, + "default_project_id": { + "title": "Default Project Id", + "type": "string", + "description": "User default project ID" + }, "version": { "title": "Version", "type": "string",