diff --git a/ui/operator_ui/main.py b/ui/operator_ui/main.py index d0f1d3911..ec33d6776 100644 --- a/ui/operator_ui/main.py +++ b/ui/operator_ui/main.py @@ -16,15 +16,11 @@ from flask import ( Flask, Response, abort, - redirect, render_template, request, send_from_directory, - session, ) -from flask_oauthlib.client import OAuth -from functools import wraps from gevent import sleep, spawn from gevent.pywsgi import WSGIServer from jq import jq @@ -34,11 +30,9 @@ from os import getenv from re import X, compile from requests.exceptions import RequestException from signal import SIGTERM, signal -from urllib.parse import urljoin from . import __version__ from .cluster_discovery import DEFAULT_CLUSTERS, StaticClusterDiscoverer -from .oauth import OAuthRemoteAppWithRefresh from .spiloutils import ( apply_postgresql, @@ -71,11 +65,8 @@ logger = getLogger(__name__) SERVER_STATUS = {'shutdown': False} APP_URL = getenv('APP_URL') -AUTHORIZE_URL = getenv('AUTHORIZE_URL') SPILO_S3_BACKUP_BUCKET = getenv('SPILO_S3_BACKUP_BUCKET') TEAM_SERVICE_URL = getenv('TEAM_SERVICE_URL') -ACCESS_TOKEN_URL = getenv('ACCESS_TOKEN_URL') -TOKENINFO_URL = getenv('OAUTH2_TOKEN_INFO_URL') OPERATOR_API_URL = getenv('OPERATOR_API_URL', 'http://postgres-operator') OPERATOR_CLUSTER_NAME_LABEL = getenv('OPERATOR_CLUSTER_NAME_LABEL', 'cluster-name') @@ -184,38 +175,6 @@ class WSGITransferEncodingChunked: return self.app(environ, start_response) -oauth = OAuth(app) - -auth = OAuthRemoteAppWithRefresh( - oauth, - 'auth', - request_token_url=None, - access_token_method='POST', - access_token_url=ACCESS_TOKEN_URL, - authorize_url=AUTHORIZE_URL, -) -oauth.remote_apps['auth'] = auth - - -def verify_token(token): - if not token: - return False - - r = requests.get(TOKENINFO_URL, headers={'Authorization': token}) - - return r.status_code == 200 - - -def authorize(f): - @wraps(f) - def wrapper(*args, **kwargs): - if AUTHORIZE_URL and 'auth_token' not in session: - return redirect(urljoin(APP_URL, '/login')) - return f(*args, **kwargs) - - return wrapper - - def ok(body={}, status=200): return ( Response( @@ -297,19 +256,16 @@ STATIC_HEADERS = { @app.route('/css/') -@authorize def send_css(path): return send_from_directory('static/', path), 200, STATIC_HEADERS @app.route('/js/') -@authorize def send_js(path): return send_from_directory('static/', path), 200, STATIC_HEADERS @app.route('/') -@authorize def index(): return render_template('index.html', google_analytics=GOOGLE_ANALYTICS) @@ -345,7 +301,6 @@ DEFAULT_UI_CONFIG = { @app.route('/config') -@authorize def get_config(): config = DEFAULT_UI_CONFIG.copy() config.update(OPERATOR_UI_CONFIG) @@ -407,17 +362,15 @@ def get_teams_for_user(user_name): @app.route('/teams') -@authorize def get_teams(): return ok( get_teams_for_user( - session.get('user_name', ''), + request.headers.get('X-Uid', ''), ) ) @app.route('/services//') -@authorize def get_service(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: @@ -433,7 +386,6 @@ def get_service(namespace: str, cluster: str): @app.route('/pooler//') -@authorize def get_list_poolers(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: @@ -449,7 +401,6 @@ def get_list_poolers(namespace: str, cluster: str): @app.route('/statefulsets//') -@authorize def get_list_clusters(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: @@ -465,7 +416,6 @@ def get_list_clusters(namespace: str, cluster: str): @app.route('/statefulsets///pods') -@authorize def get_list_members(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: @@ -485,7 +435,6 @@ def get_list_members(namespace: str, cluster: str): @app.route('/namespaces') -@authorize def get_namespaces(): if TARGET_NAMESPACE not in ['', '*']: @@ -503,7 +452,6 @@ def get_namespaces(): @app.route('/postgresqls') -@authorize def get_postgresqls(): postgresqls = [ { @@ -602,7 +550,6 @@ def read_only(handler): @app.route('/postgresqls//', methods=['POST']) -@authorize @namespaced def update_postgresql(namespace: str, cluster: str): if READ_ONLY_MODE: @@ -614,8 +561,8 @@ def update_postgresql(namespace: str, cluster: str): postgresql = request.get_json(force=True) - teams = get_teams_for_user(session.get('user_name', '')) - logger.info(f'Changes to: {cluster} by {session.get("user_name", "local-user")}/{teams} {postgresql}') # noqa + teams = get_teams_for_user(request.headers.get('X-Uid', '')) + logger.info(f'Changes to: {cluster} by {request.headers.get("X-Uid", "local-user")}/{teams} {postgresql}') # noqa if SUPERUSER_TEAM and SUPERUSER_TEAM in teams: logger.info(f'Allowing edit due to membership in superuser team {SUPERUSER_TEAM}') # noqa @@ -810,7 +757,6 @@ def update_postgresql(namespace: str, cluster: str): @app.route('/postgresqls//', methods=['GET']) -@authorize def get_postgresql(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: @@ -826,7 +772,6 @@ def get_postgresql(namespace: str, cluster: str): @app.route('/stored_clusters') -@authorize def get_stored_clusters(): return respond( read_stored_clusters( @@ -837,7 +782,6 @@ def get_stored_clusters(): @app.route('/stored_clusters/', methods=['GET']) -@authorize def get_versions(pg_cluster: str): return respond( read_versions( @@ -850,9 +794,7 @@ def get_versions(pg_cluster: str): ) - @app.route('/stored_clusters//', methods=['GET']) -@authorize def get_basebackups(pg_cluster: str, uid: str): return respond( read_basebackups( @@ -867,7 +809,6 @@ def get_basebackups(pg_cluster: str, uid: str): @app.route('/create-cluster', methods=['POST']) -@authorize def create_new_cluster(): if READ_ONLY_MODE: @@ -885,8 +826,8 @@ def create_new_cluster(): if TARGET_NAMESPACE not in ['', '*', namespace]: return wrong_namespace() - teams = get_teams_for_user(session.get('user_name', '')) - logger.info(f'Create cluster by {session.get("user_name", "local-user")}/{teams} {postgresql}') # noqa + teams = get_teams_for_user(request.headers.get('X-Uid', '')) + logger.info(f'Create cluster by {request.headers.get("X-Uid", "local-user")}/{teams} {postgresql}') # noqa if SUPERUSER_TEAM and SUPERUSER_TEAM in teams: logger.info(f'Allowing create due to membership in superuser team {SUPERUSER_TEAM}') # noqa @@ -898,7 +839,6 @@ def create_new_cluster(): @app.route('/postgresqls//', methods=['DELETE']) -@authorize def delete_postgresql(namespace: str, cluster: str): if TARGET_NAMESPACE not in ['', '*', namespace]: return wrong_namespace() @@ -910,9 +850,9 @@ def delete_postgresql(namespace: str, cluster: str): if postgresql is None: return not_found() - teams = get_teams_for_user(session.get('user_name', '')) + teams = get_teams_for_user(request.headers.get('X-Uid', '')) - logger.info(f'Delete cluster: {cluster} by {session.get("user_name", "local-user")}/{teams}') # noqa + logger.info(f'Delete cluster: {cluster} by {request.headers.get("X-Uid", "local-user")}/{teams}') # noqa if SUPERUSER_TEAM and SUPERUSER_TEAM in teams: logger.info(f'Allowing delete due to membership in superuser team {SUPERUSER_TEAM}') # noqa @@ -936,78 +876,30 @@ def proxy_operator(url: str): @app.route('/operator/status') -@authorize def get_operator_status(): return proxy_operator('/status/') @app.route('/operator/workers//queue') -@authorize def get_operator_get_queue(worker: int): return proxy_operator(f'/workers/{worker}/queue') @app.route('/operator/workers//logs') -@authorize def get_operator_get_logs(worker: int): return proxy_operator(f'/workers/{worker}/logs') @app.route('/operator/clusters///logs') -@authorize def get_operator_get_logs_per_cluster(namespace: str, cluster: str): return proxy_operator(f'/clusters/{namespace}/{cluster}/logs/') -@app.route('/login') -def login(): - redirect = request.args.get('redirect', False) - if not redirect: - return render_template('login-deeplink.html') - - redirect_uri = urljoin(APP_URL, '/login/authorized') - return auth.authorize(callback=redirect_uri) - - -@app.route('/logout') -def logout(): - session.pop('auth_token', None) - return redirect(urljoin(APP_URL, '/')) - - @app.route('/favicon.png') def favicon(): return send_from_directory('static/', 'favicon-96x96.png'), 200 -@app.route('/login/authorized') -def authorized(): - resp = auth.authorized_response() - if resp is None: - return 'Access denied: reason=%s error=%s' % ( - request.args['error'], - request.args['error_description'] - ) - - if not isinstance(resp, dict): - return 'Invalid auth response' - - session['auth_token'] = (resp['access_token'], '') - - r = requests.get( - TOKENINFO_URL, - headers={ - 'Authorization': f'Bearer {session["auth_token"][0]}', - }, - ) - session['user_name'] = r.json().get('uid') - - logger.info(f'Login from: {session["user_name"]}') - - # return redirect(urljoin(APP_URL, '/')) - return render_template('login-resolve-deeplink.html') - - def shutdown(): # just wait some time to give Kubernetes time to update endpoints # this requires changing the readinessProbe's @@ -1083,28 +975,20 @@ def init_cluster(): help='Verbose logging', is_flag=True, ) -@option( - '--secret-key', - default='development', - envvar='SECRET_KEY', - help='Secret key for session cookies', -) @option( '--clusters', envvar='CLUSTERS', help=f'Comma separated list of Kubernetes API server URLs (default: {DEFAULT_CLUSTERS})', # noqa type=CommaSeparatedValues(), ) -def main(port, secret_key, debug, clusters: list): +def main(port, debug, clusters: list): global TARGET_NAMESPACE basicConfig(stream=sys.stdout, level=(DEBUG if debug else INFO), format='%(asctime)s %(levelname)s: %(message)s',) init_cluster() - logger.info(f'Access token URL: {ACCESS_TOKEN_URL}') logger.info(f'App URL: {APP_URL}') - logger.info(f'Authorize URL: {AUTHORIZE_URL}') logger.info(f'Operator API URL: {OPERATOR_API_URL}') logger.info(f'Operator cluster name label: {OPERATOR_CLUSTER_NAME_LABEL}') logger.info(f'Readonly mode: {"enabled" if READ_ONLY_MODE else "disabled"}') # noqa @@ -1113,7 +997,6 @@ def main(port, secret_key, debug, clusters: list): logger.info(f'Superuser team: {SUPERUSER_TEAM}') logger.info(f'Target namespace: {TARGET_NAMESPACE}') logger.info(f'Teamservice URL: {TEAM_SERVICE_URL}') - logger.info(f'Tokeninfo URL: {TOKENINFO_URL}') logger.info(f'Use AWS instance_profile: {USE_AWS_INSTANCE_PROFILE}') logger.info(f'WAL-E S3 endpoint: {WALE_S3_ENDPOINT}') logger.info(f'AWS S3 endpoint: {AWS_ENDPOINT}') @@ -1136,7 +1019,6 @@ def main(port, secret_key, debug, clusters: list): logger.info(f'Target namespace set to: {TARGET_NAMESPACE or "*"}') app.debug = debug - app.secret_key = secret_key signal(SIGTERM, exit_gracefully) diff --git a/ui/operator_ui/oauth.py b/ui/operator_ui/oauth.py deleted file mode 100644 index 34c07fd4f..000000000 --- a/ui/operator_ui/oauth.py +++ /dev/null @@ -1,32 +0,0 @@ -import os - -from flask_oauthlib.client import OAuthRemoteApp - - -CREDENTIALS_DIR = os.getenv('CREDENTIALS_DIR', '') - - -class OAuthRemoteAppWithRefresh(OAuthRemoteApp): - '''Same as flask_oauthlib.client.OAuthRemoteApp, but always loads client credentials from file.''' - - def __init__(self, oauth, name, **kwargs): - # constructor expects some values, so make it happy.. - kwargs['consumer_key'] = 'not-needed-here' - kwargs['consumer_secret'] = 'not-needed-here' - OAuthRemoteApp.__init__(self, oauth, name, **kwargs) - - def refresh_credentials(self): - with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-id')) as fd: - self._consumer_key = fd.read().strip() - with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-secret')) as fd: - self._consumer_secret = fd.read().strip() - - @property - def consumer_key(self): - self.refresh_credentials() - return self._consumer_key - - @property - def consumer_secrect(self): - self.refresh_credentials() - return self._consumer_secret diff --git a/ui/operator_ui/templates/index.html b/ui/operator_ui/templates/index.html index 7307c8a3a..60a31e190 100644 --- a/ui/operator_ui/templates/index.html +++ b/ui/operator_ui/templates/index.html @@ -3,7 +3,13 @@ PostgreSQL Operator UI - + diff --git a/ui/operator_ui/templates/login-deeplink.html b/ui/operator_ui/templates/login-deeplink.html deleted file mode 100644 index 875b8d055..000000000 --- a/ui/operator_ui/templates/login-deeplink.html +++ /dev/null @@ -1,13 +0,0 @@ - - - Storing client location ... - - - - - \ No newline at end of file diff --git a/ui/operator_ui/templates/login-resolve-deeplink.html b/ui/operator_ui/templates/login-resolve-deeplink.html deleted file mode 100644 index fac96b265..000000000 --- a/ui/operator_ui/templates/login-resolve-deeplink.html +++ /dev/null @@ -1,18 +0,0 @@ - - - Restoring client location ... - - - - - diff --git a/ui/requirements.txt b/ui/requirements.txt index 49e606fb0..97d1467c6 100644 --- a/ui/requirements.txt +++ b/ui/requirements.txt @@ -1,15 +1,14 @@ backoff==2.2.1 -boto3==1.26.51 +boto3==1.34.110 boto==2.49.0 -click==8.1.3 -Flask-OAuthlib==0.9.6 -Flask==2.3.2 +click==8.1.7 +Flask==3.0.3 furl==2.1.3 -gevent==23.9.1 -jq==1.4.0 +gevent==24.2.1 +jq==1.7.0 json_delta>=2.0.2 kubernetes==11.0.0 -requests==2.31.0 +requests==2.32.2 stups-tokens>=1.1.19 wal_e==1.1.1 -werkzeug==2.3.3 +werkzeug==3.0.3