feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 37099ddf7e
commit 8b7ce77cec
271 changed files with 31166 additions and 6513 deletions
@@ -1,14 +1,53 @@
import quart
import argon2
import asyncio
import traceback
from urllib.parse import parse_qs, urlsplit
from .. import group
from .....entity.errors import account as account_errors
from ...context import RequestContext
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
parsed.scheme not in {'http', 'https'}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or parsed.path != '/auth/space/callback'
):
raise ValueError('Invalid redirect_uri parameter')
query = parse_qs(parsed.query, keep_blank_values=True)
if bind:
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
raise ValueError('Invalid Space login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -23,7 +62,12 @@ class UserRouterGroup(group.RouterGroup):
user_email = json_data['user']
password = json_data['password']
await self.ap.user_service.create_user(user_email, password)
try:
await self.ap.user_service.create_user(user_email, password)
except ControlPlaneDirectoryRequiredError as exc:
return self.http_status(409, exc.code, str(exc))
except PublicRegistrationClosedError:
return self.http_status(409, 'registration_closed', 'System already initialized')
return self.success()
@@ -40,7 +84,7 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'token': token})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(user_email: str) -> str:
token = await self.ap.user_service.generate_jwt_token(user_email)
@@ -101,15 +145,37 @@ class UserRouterGroup(group.RouterGroup):
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
if 'state' in quart.request.args:
return self.fail(1, 'Caller-supplied OAuth state is not allowed')
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
state = await self.ap.user_service.issue_space_oauth_state('login')
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except Exception as e:
except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
"""Issue an account-bound, one-time Space OAuth redirect."""
redirect_uri = quart.request.args.get('redirect_uri', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
if not request_context.account_uuid:
return self.http_status(403, 'account_required', 'An Account is required')
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=True)
state = await self.ap.user_service.issue_space_oauth_state(
'bind',
account_uuid=request_context.account_uuid,
)
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
@@ -117,11 +183,15 @@ class UserRouterGroup(group.RouterGroup):
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
if not code:
return self.fail(1, 'Missing authorization code')
if not state:
return self.fail(1, 'Missing state parameter')
try:
await self.ap.user_service.consume_space_oauth_state(state, 'login')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -142,15 +212,15 @@ class UserRouterGroup(group.RouterGroup):
'user': user_obj.user,
}
)
except ControlPlaneDirectoryRequiredError as e:
return self.http_status(409, e.code, str(e))
except account_errors.AccountEmailMismatchError as e:
return self.fail(3, str(e))
except ValueError as e:
traceback.print_exc()
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
return self.fail(1, str(e))
except Exception as e:
traceback.print_exc()
return self.fail(2, f'OAuth callback failed: {str(e)}')
except ValueError:
self.ap.logger.exception('Space OAuth callback failed')
return self.fail(1, 'Space OAuth failed')
except Exception:
raise
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
@@ -162,6 +232,7 @@ class UserRouterGroup(group.RouterGroup):
return self.success(
data={
'account_uuid': user_obj.uuid,
'user': user_obj.user,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
@@ -176,19 +247,18 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""
"""Return instance login capabilities without disclosing an account."""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
user_obj = await self.ap.user_service.get_first_user()
if user_obj is None:
return self.success(data={'initialized': False})
return self.success(
data={
'initialized': True,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
# Login is selected per account in a multi-user instance. A public
# bootstrap endpoint must never project one user's authentication
# methods onto every other user or disclose that user's state.
'password_login_enabled': True,
'space_login_enabled': True,
}
)
@@ -233,7 +303,7 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state') # JWT token passed as state
state = json_data.get('state')
if not code:
return self.http_status(400, -1, 'Missing authorization code')
@@ -241,13 +311,10 @@ class UserRouterGroup(group.RouterGroup):
if not state:
return self.http_status(400, -1, 'Missing state parameter')
# Verify state is a valid JWT token
try:
user_email = await self.ap.user_service.verify_jwt_token(state)
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
@@ -255,8 +322,8 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
'token': jwt_token,
@@ -264,7 +331,8 @@ class UserRouterGroup(group.RouterGroup):
'account_type': updated_user.account_type,
}
)
except ValueError as e:
return self.http_status(400, -1, str(e))
except Exception as e:
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
except ValueError:
self.ap.logger.exception('Space account binding failed')
return self.http_status(400, -1, 'Space account binding failed')
except Exception:
raise