From e9161cb3ae10a7332833470a3b397ac40e9023de Mon Sep 17 00:00:00 2001 From: Syed Ahmed Date: Tue, 24 Sep 2024 11:32:38 -0400 Subject: [PATCH] robots: Add robot federation for keyless auth (PROJQUAY-7803) (#3207) robots: Add robot federation for keyless auth (PROJQUAY-7652) adds the ability to configure federated auth for robots by using external OIDC providers. Each robot can be configured to have multiple external OIDC providers as the source for authentication. --- app.py | 1 - auth/credentials.py | 10 +- auth/decorators.py | 2 + auth/oauth.py | 2 +- auth/test/mock_oidc_server.py | 116 ++++++++ auth/test/test_federated_robot_auth.py | 110 ++++++++ auth/validateresult.py | 1 + ..._add_log_event_kind_for_federated_robot.py | 35 +++ data/model/test/test_user.py | 27 +- data/model/user.py | 89 +++++- endpoints/api/robot.py | 111 ++++++++ endpoints/api/test/test_robot.py | 104 ++++++- endpoints/api/test/test_security.py | 34 ++- endpoints/oauth/robot_identity_federation.py | 34 +++ initdb.py | 5 + oauth/login_utils.py | 12 - oauth/oidc.py | 3 +- tox.ini | 2 + util/security/federated_robot_auth.py | 116 ++++++++ util/security/instancekeys.py | 4 + util/security/jwtutil.py | 15 +- util/security/registry_jwt.py | 6 +- web.py | 2 + .../modals/RobotFederationModal.tsx | 255 ++++++++++++++++++ web/src/hooks/useRobotFederation.ts | 59 ++++ web/src/resources/RobotsResource.ts | 36 +++ .../RepositoriesList/RobotAccountKebab.tsx | 18 +- .../RepositoriesList/RobotAccountsList.tsx | 17 ++ web/src/routes/UsageLogs/UsageLogsGraph.tsx | 4 + web/webpack.dev.js | 4 + 30 files changed, 1191 insertions(+), 43 deletions(-) create mode 100644 auth/test/mock_oidc_server.py create mode 100644 auth/test/test_federated_robot_auth.py create mode 100644 data/migrations/versions/9085e82074f2_add_log_event_kind_for_federated_robot.py create mode 100644 endpoints/oauth/robot_identity_federation.py create mode 100644 util/security/federated_robot_auth.py create mode 100644 web/src/components/modals/RobotFederationModal.tsx create mode 100644 web/src/hooks/useRobotFederation.ts diff --git a/app.py b/app.py index 0b83bead72..d134e12d19 100644 --- a/app.py +++ b/app.py @@ -252,7 +252,6 @@ def _request_end(resp): sentry = Sentry(app) build_logs = BuildLogs(app) userevents = UserEventsBuilderModule(app) -instance_keys = InstanceKeys(app) label_validator = LabelValidator(app) build_canceller = BuildCanceller(app) diff --git a/auth/credentials.py b/auth/credentials.py index 183f85b8e8..e68582cd3b 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -2,9 +2,10 @@ from enum import Enum from flask import request +from jwt import InvalidTokenError import features -from app import app, authentication +from app import app, authentication, instance_keys from auth.credential_consts import ( ACCESS_TOKEN_USERNAME, APP_SPECIFIC_TOKEN_USERNAME, @@ -119,10 +120,9 @@ def validate_credentials(auth_username, auth_password_or_token): if is_robot: logger.debug("Found credentials header for robot %s", auth_username) try: - robot = model.user.verify_robot(auth_username, auth_password_or_token) - + robot = model.user.verify_robot(auth_username, auth_password_or_token, instance_keys) + assert robot logger.debug("Successfully validated credentials for robot %s", auth_username) - return ValidateResult(AuthKind.credentials, robot=robot), CredentialKind.robot except model.DeactivatedRobotOwnerException as dre: robot_owner, robot_name = parse_robot_username(auth_username) @@ -155,7 +155,7 @@ def validate_credentials(auth_username, auth_password_or_token): ValidateResult(AuthKind.credentials, error_message=str(dre)), CredentialKind.robot, ) - except model.InvalidRobotCredentialException as ire: + except (model.InvalidRobotCredentialException, InvalidTokenError) as ire: logger.debug("Failed to validate credentials for robot %s: %s", auth_username, ire) if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): diff --git a/auth/decorators.py b/auth/decorators.py index 17cd9ed0c2..08410f1dd5 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -10,6 +10,7 @@ from auth.signedgrant import validate_signed_grant from auth.validateresult import AuthKind from util.http import abort +from util.security.federated_robot_auth import validate_federated_auth logger = logging.getLogger(__name__) @@ -77,6 +78,7 @@ def wrapper(*args, **kwargs): process_auth_or_cookie = _auth_decorator(handlers=[validate_basic_auth, validate_session_cookie]) process_basic_auth = _auth_decorator(handlers=[validate_basic_auth], pass_result=True) process_basic_auth_no_pass = _auth_decorator(handlers=[validate_basic_auth]) +process_federated_auth = _auth_decorator(handlers=[validate_federated_auth], pass_result=True) def require_session_login(func): diff --git a/auth/oauth.py b/auth/oauth.py index 4b70e39fa4..65509d0da9 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -14,9 +14,9 @@ _conduct_oauth_login, get_jwt_issuer, get_sub_username_email_from_token, - is_jwt, ) from oauth.oidc import PublicKeyLoadException +from util.security.jwtutil import is_jwt logger = logging.getLogger(__name__) diff --git a/auth/test/mock_oidc_server.py b/auth/test/mock_oidc_server.py new file mode 100644 index 0000000000..dfd8a23e6c --- /dev/null +++ b/auth/test/mock_oidc_server.py @@ -0,0 +1,116 @@ +# Mock OIDC discovery and token endpoint data +import datetime +import json +import uuid + +import jwt + +MOCK_DISCOVERY_RESPONSE = { + "issuer": "https://mock-oidc-server.com", + "authorization_endpoint": "https://mock-oidc-server.com/authorize", + "token_endpoint": "https://mock-oidc-server.com/token", + "jwks_uri": "https://mock-oidc-server.com/.well-known/jwks.json", + "userinfo_endpoint": "https://mock-oidc-server.com/userinfo", + "response_types_supported": ["code", "id_token", "token id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], +} + +MOCK_PRIVATE_KEY = """ +-----BEGIN RSA PRIVATE KEY----- +MIICXwIBAAKBgQDHd3NJdianKlLgzUmuc/fqYr/xFEDV7Ud3bPnO1N2r5UST7Rlj +XkY2aEf6EL/4FvFZlKW/W6vwFelPMuAZGlZR717IABtj2YLpH8HnO53HqofezZHw +QsahHwxmPJLXAl7Q4sdEg+/06bzsrFlYPWBftWpWKtUiPPK2KtmGdPFEEQIDAQAB +AoGBAKIYNj36oAq04EkDSt9UKqH0wdqeBNpUSwGIM7GbVtD8LbCwuzL/R7urHuLe +fcKUkmmj3NYXHzCp/cF4rJh5yK6317oim3MJjELYyY9K8eAZ2QRO/66JhphZqOD0 +XJ6iYqxvX62vxqoixvlXDhWLm3Gtv/57dKGgy5jkjhZUYHphAkEA+haxmLvTKgDD +9yDVOjv2iEPrn1IBDeYRrGcl4byZPzwXmtp7RuXtxdB1irtkoagdjySeYglIdOJ6 ++EqKtP/bPQJBAMwucEeQYAHaIHFpYORaY+VlgCT97gcj08BHZByDm5YA0oQxIi+W +jMz0NCdDT9eqUAGszZ6T5PvsOtnFvPOfKWUCQQDzujYuwa4UG1bge7ES5eln97mk +NYktgHDs8kGq8+DuDaR7mD3YZLELvhMvt11lZrAYFvn8VUu2DhsF66+uokOJAkEA +vw14/E2ouDLthpFvG11E+iJWnMaKUl4AxntGvrObAuo0EYOUFGlPyHt8zXxbmlZ/ +1IFoSUjjy6KIkrtHCcLVTQJBAJB0NIhj1E8PdES5+s9XfqnMttK4V8lc46bb/3+U +2H0hVBT7vR5sr+QjzEYSATW14c/9QBskZgsbtSEz6zf9+qU= +-----END RSA PRIVATE KEY----- +""" + +MOCK_PUBLIC_KEY = """ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHd3NJdianKlLgzUmuc/fqYr/x +FEDV7Ud3bPnO1N2r5UST7RljXkY2aEf6EL/4FvFZlKW/W6vwFelPMuAZGlZR717I +ABtj2YLpH8HnO53HqofezZHwQsahHwxmPJLXAl7Q4sdEg+/06bzsrFlYPWBftWpW +KtUiPPK2KtmGdPFEEQIDAQAB +-----END PUBLIC KEY----- +""" + +MOCK_JWKS_RESPONSE = { + "keys": [ + { + "kty": "RSA", + "n": "x3dzSXYmpypS4M1JrnP36mK_8RRA1e1Hd2z5ztTdq-VEk-0ZY15GNmhH-hC_-BbxWZSlv1ur8BXpTzLgGRpWUe9eyAAbY9mC6R_B5zudx6qH3s2R8ELGoR8MZjyS1wJe0OLHRIPv9Om87KxZWD1gX7VqVirVIjzytirZhnTxRBE", + "e": "AQAB", + "kid": "mock-key-id", + } + ] +} + + +# Mock for discovery, JWKS, and token endpoints +def mock_get(obj, url, *args, **kwargs): + if url == "https://mock-oidc-server.com/.well-known/openid-configuration": + return MockResponse(MOCK_DISCOVERY_RESPONSE, 200) + elif url == "https://mock-oidc-server.com/.well-known/jwks.json": + return MockResponse(MOCK_JWKS_RESPONSE, 200) + return MockResponse({}, 404) + + +def mock_request(obj, method, url, *args, **kwargs): + return mock_get(None, url, *args, **kwargs) + + +class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + @property + def text(self): + return json.dumps(self.json_data) + + +def generate_mock_oidc_token( + issuer="https://mock-oidc-server.com", + subject="mock-subject", + audience="mock-client-id", + expiry_seconds=3600, + issued_at=None, +): + now = datetime.datetime.now() + iat = now - datetime.timedelta(seconds=30) + if issued_at is not None: + iat = issued_at + + exp = iat + datetime.timedelta(seconds=expiry_seconds) + + payload = { + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": int(exp.timestamp()), + "iat": int(iat.timestamp()), + "nbf": int(iat.timestamp()), + "nonce": str(uuid.uuid4()), + "name": "Mock User", + "preferred_username": "mockuser", + "given_name": "Mock", + "family_name": "User", + "email": "mockuser@test.com", + "email_verified": True, + } + + headers = {"kid": "mock-key-id"} + + return jwt.encode(payload, MOCK_PRIVATE_KEY, algorithm="RS256", headers=headers) diff --git a/auth/test/test_federated_robot_auth.py b/auth/test/test_federated_robot_auth.py new file mode 100644 index 0000000000..fa2e64bc98 --- /dev/null +++ b/auth/test/test_federated_robot_auth.py @@ -0,0 +1,110 @@ +import base64 +import datetime +from unittest.mock import patch + +import pytest +import requests +from jwt import DecodeError + +from auth.test.mock_oidc_server import generate_mock_oidc_token, mock_get, mock_request +from auth.validateresult import AuthKind, ValidateResult +from data import model +from data.model import InvalidRobotCredentialException, InvalidRobotException +from test.fixtures import * +from util.security.federated_robot_auth import validate_federated_auth + + +def test_validate_federated_robot_auth_bad_header(app): + header = "Basic bad-basic-auth-header" + result = validate_federated_auth(header) + assert result == ValidateResult( + AuthKind.federated, missing=True, error_message="Could not parse basic auth header" + ) + + +def test_validate_federated_robot_auth_no_header(app): + result = validate_federated_auth("") + assert result == ValidateResult( + AuthKind.federated, missing=True, error_message="No auth header" + ) + + +def test_validate_federated_robot_auth_invalid_robot_name(app): + creds = base64.b64encode("nonrobotuser:password".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + result = validate_federated_auth(header) + assert result == ValidateResult(AuthKind.federated, missing=True, error_message="Invalid robot") + + +def test_validate_federated_robot_auth_non_existing_robot(app): + creds = base64.b64encode("someorg+somerobot:password".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + + with pytest.raises(InvalidRobotException) as err: + validate_federated_auth(header) + + assert "Could not find robot with specified username" in str(err) + + +def test_validate_federated_robot_auth_invalid_jwt(app): + robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) + creds = base64.b64encode(f"{robot.username}:{password}".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + with pytest.raises(DecodeError) as e: + validate_federated_auth(header) + + +def test_validate_federated_robot_auth_no_fed_config(app): + robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) + token = generate_mock_oidc_token(subject=robot.username) + creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + with pytest.raises(InvalidRobotCredentialException) as e: + result = validate_federated_auth(header) + + assert "Robot does not have federated login configured" in str(e) + + +@patch.object(requests.Session, "request", mock_request) +@patch.object(requests.Session, "get", mock_get) +def test_validate_federated_robot_auth_expired_jwt(app): + robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) + fed_config = [ + { + "issuer": "https://mock-oidc-server.com", + "subject": robot.username, + } + ] + + iat = datetime.datetime.now() - datetime.timedelta(seconds=4000) + + model.user.create_robot_federation_config(robot, fed_config) + token = generate_mock_oidc_token(subject=robot.username, issued_at=iat) + creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + + with pytest.raises(InvalidRobotCredentialException) as e: + validate_federated_auth(header) + assert "Signature has expired" in str(e) + + +@patch.object(requests.Session, "request", mock_request) +@patch.object(requests.Session, "get", mock_get) +def test_validate_federated_robot_auth_valid_jwt(app): + robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) + fed_config = [ + { + "issuer": "https://mock-oidc-server.com", + "subject": robot.username, + } + ] + model.user.create_robot_federation_config(robot, fed_config) + + token = generate_mock_oidc_token(subject=robot.username) + creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) + header = f"Basic {creds.decode('utf-8')}" + + result: ValidateResult = validate_federated_auth(header) + assert result.error_message is None + assert not result.missing + assert result.kind == AuthKind.federated diff --git a/auth/validateresult.py b/auth/validateresult.py index 39aee54df9..668d1d400a 100644 --- a/auth/validateresult.py +++ b/auth/validateresult.py @@ -10,6 +10,7 @@ class AuthKind(Enum): signed_grant = "signed_grant" credentials = "credentials" ssojwt = "ssojwt" + federated = "federated" def __str__(self): return "%s" % self.value diff --git a/data/migrations/versions/9085e82074f2_add_log_event_kind_for_federated_robot.py b/data/migrations/versions/9085e82074f2_add_log_event_kind_for_federated_robot.py new file mode 100644 index 0000000000..c0f5da3081 --- /dev/null +++ b/data/migrations/versions/9085e82074f2_add_log_event_kind_for_federated_robot.py @@ -0,0 +1,35 @@ +"""Add log event kind for federated robot + +Revision ID: 9085e82074f2 +Revises: a32e17bfad20 +Create Date: 2024-09-09 15:49:24.911854 + +""" + +# revision identifiers, used by Alembic. +revision = "9085e82074f2" +down_revision = "ba263f9be4a6" + +import sqlalchemy as sa + + +def upgrade(op, tables, tester): + op.bulk_insert( + tables.logentrykind, + [ + {"name": "create_robot_federation"}, + {"name": "delete_robot_federation"}, + {"name": "federated_robot_token_exchange"}, + ], + ) + + +def downgrade(op, tables, tester): + op.execute( + tables.logentrykind.delete().where( + tables.logentrykind.c.name + == op.inline_literal("create_robot_federation") | tables.logentrykind.c.name + == op.inline_literal("delete_robot_federation") | tables.logentrykind.c.name + == op.inline_literal("federated_robot_token_exchange") + ) + ) diff --git a/data/model/test/test_user.py b/data/model/test/test_user.py index 4c6b77d254..c69116e625 100644 --- a/data/model/test/test_user.py +++ b/data/model/test/test_user.py @@ -1,9 +1,11 @@ from datetime import datetime +from unittest.mock import Mock import pytest from mock import patch from auth.scopes import READ_REPO +from auth.test.mock_oidc_server import MOCK_PUBLIC_KEY, generate_mock_oidc_token from data import model from data.database import DeletedNamespace, EmailConfirmation, FederatedLogin, User from data.fields import Credential @@ -46,6 +48,7 @@ from data.queue import WorkQueue from test.fixtures import * from test.helpers import check_transitive_modifications +from util.security.instancekeys import InstanceKeys from util.security.token import encode_public_private_token from util.timedeltastring import convert_to_timedelta @@ -332,13 +335,31 @@ def test_robot(initialized_db): assert creds["username"] == "foobar+foo" assert creds["password"] == token - assert verify_robot("foobar+foo", token) == robot + assert verify_robot("foobar+foo", token, None) == robot with pytest.raises(InvalidRobotException): - assert verify_robot("foobar+foo", "someothertoken") + assert verify_robot("foobar+foo", "someothertoken", None) with pytest.raises(InvalidRobotException): - assert verify_robot("foobar+unknownbot", token) + assert verify_robot("foobar+unknownbot", token, None) + + +def test_jwt_robot_token(initialized_db): + user = get_user("devtable") + org = create_organization("foobar", "foobar@devtable.com", user) + create_robot("foo", org) + + mock_oidc_token = generate_mock_oidc_token( + issuer="quay", audience="quay-aud", subject="foobar+foo" + ) + robot, token = create_robot("foo", user) + + mock_instance_keys = Mock(InstanceKeys) + mock_instance_keys.service_name = "quay" + mock_instance_keys.get_service_key_public_key = Mock(return_value=MOCK_PUBLIC_KEY) + + with patch("data.model.config.app_config", {"SERVER_HOSTNAME": "quay-aud"}): + verify_robot("foobar+foo", mock_oidc_token, mock_instance_keys) def test_get_estimated_robot_count(initialized_db): diff --git a/data/model/user.py b/data/model/user.py index f40201e582..104d551bf5 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -8,6 +8,7 @@ from flask_login import UserMixin from peewee import JOIN, IntegrityError, fn +from auth.auth_context import get_authenticated_context from data.database import ( AutoPruneTaskStatus, DeletedNamespace, @@ -67,6 +68,13 @@ from util.backoff import exponential_backoff from util.bytes import Bytes from util.names import format_robot_username, parse_robot_username +from util.security.jwtutil import is_jwt +from util.security.registry_jwt import ( + InvalidBearerTokenException, + build_context_and_subject, + decode_bearer_token, + generate_bearer_token, +) from util.security.token import decode_public_private_token, encode_public_private_token from util.timedeltastring import convert_to_timedelta from util.validation import ( @@ -79,8 +87,8 @@ logger = logging.getLogger(__name__) - EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1) +TMP_ROBOT_TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour def hash_password(password, salt=None): @@ -373,6 +381,41 @@ def create_robot(robot_shortname, parent, description="", unstructured_metadata= raise DataModelException(ex) +def get_robot_federation_config(robot): + federated_robot = FederatedLogin.select().where(FederatedLogin.user == robot).get() + assert federated_robot + + metadata = {} + try: + metadata = json.loads(federated_robot.metadata_json) + except Exception as e: + logger.debug("Error parsing metadata: %s", e) + + return metadata.get("federation_config", []) + + +def create_robot_federation_config(robot, fed_config): + federated_robot = FederatedLogin.select().where(FederatedLogin.user == robot).get() + assert federated_robot + + metadata = {} + try: + metadata = json.loads(federated_robot.metadata_json) + except Exception as e: + logger.debug("Error parsing metadata: %s", e) + + try: + metadata["federation_config"] = fed_config + federated_robot.metadata_json = json.dumps(metadata) + federated_robot.save() + except Exception as e: + raise DataModelException(e) + + +def delete_robot_federation_config(robot): + create_robot_federation_config(robot, []) + + def get_or_create_robot_metadata(robot): defaults = dict(description="", unstructured_json={}) metadata, _ = RobotAccountMetadata.get_or_create(robot_account=robot, defaults=defaults) @@ -425,6 +468,24 @@ def lookup_robot_and_metadata(robot_username): return robot, get_or_create_robot_metadata(robot) +def verify_robot_jwt_token(robot_username, jwt_token, instance_keys): + # a robot token can be either an ephemeral JWT token + # or an external OIDC token + # throws an exception if we cannot decode/verify the token + + decoded_token = decode_bearer_token(jwt_token, instance_keys, config.app_config) + assert decoded_token + + sub = decoded_token.get("sub") + aud = decoded_token.get("aud") + + if sub != robot_username: + raise InvalidRobotCredentialException("Token does not match robot") + + if aud != config.app_config["SERVER_HOSTNAME"]: + raise InvalidRobotCredentialException("Invalid audience for robot token") + + def get_matching_robots(name_prefix, username, limit=10): admined_orgs = ( _basequery.get_user_organizations(username) @@ -445,11 +506,12 @@ def get_matching_robots(name_prefix, username, limit=10): return User.select().where(prefix_checks).limit(limit) -def verify_robot(robot_username, password): +def verify_robot(robot_username, password, instance_keys): if config.app_config.get("ROBOTS_DISALLOW", False): if not robot_username in config.app_config.get("ROBOTS_WHITELIST", []): msg = "Robot account have been disabled. Please contact your administrator." raise InvalidRobotException(msg) + try: password.encode("ascii") except UnicodeEncodeError: @@ -473,12 +535,18 @@ def verify_robot(robot_username, password): raise DeactivatedRobotOwnerException( "Robot %s owner %s is disabled" % (robot_username, owner.username) ) + # Lookup the token for the robot. try: - token_data = RobotAccountToken.get(robot_account=robot) - if not token_data.token.matches(password): - msg = "Could not find robot with username: %s and supplied password." % robot_username - raise InvalidRobotCredentialException(msg) + if is_jwt(password): + verify_robot_jwt_token(robot_username, password, instance_keys) + else: + token_data = RobotAccountToken.get(robot_account=robot) + if not token_data.token.matches(password): + msg = ( + "Could not find robot with username: %s and supplied password." % robot_username + ) + raise InvalidRobotCredentialException(msg) except RobotAccountToken.DoesNotExist: msg = "Could not find robot with username: %s and supplied password." % robot_username raise InvalidRobotCredentialException(msg) @@ -514,6 +582,15 @@ def regenerate_robot_token(robot_shortname, parent): return robot, password, metadata +def generate_temp_robot_jwt_token(instance_keys): + context, subject = build_context_and_subject(get_authenticated_context()) + audience_param = config.app_config["SERVER_HOSTNAME"] + token = generate_bearer_token( + audience_param, subject, context, {}, TMP_ROBOT_TOKEN_VALIDITY_LIFETIME_S, instance_keys + ) + return token + + def delete_robot(robot_username): try: robot = User.get(username=robot_username, robot=True) diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py index b9319e8d9c..5835487513 100644 --- a/endpoints/api/robot.py +++ b/endpoints/api/robot.py @@ -1,6 +1,9 @@ """ Manage user and organization robot accounts. """ +import json +import logging + from flask import abort, request from auth import scopes @@ -9,7 +12,16 @@ AdministerOrganizationPermission, OrganizationMemberPermission, ) +from data.database import FederatedLogin, LoginService from data.model import InvalidRobotException +from data.model.user import ( + attach_federated_login, + create_federated_user, + create_robot_federation_config, + delete_robot_federation_config, + get_robot_federation_config, + lookup_robot, +) from endpoints.api import ( ApiResource, allow_if_global_readonly_superuser, @@ -48,8 +60,29 @@ }, } +CREATE_ROBOT_FEDERATION_SCHEMA = { + "type": "array", + "description": "Federation configuration for the robot", + "items": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "description": "The issuer of the token", + }, + "subject": { + "type": "string", + "description": "The subject of the token", + }, + }, + "required": ["issuer", "subject"], + }, +} + ROBOT_MAX_SIZE = 1024 * 1024 # 1 KB. +logger = logging.getLogger(__name__) + def robots_list(prefix, include_permissions=False, include_token=False, limit=None): robots = model.list_entity_robot_permission_teams( @@ -380,3 +413,81 @@ def post(self, orgname, robot_shortname): return robot.to_dict(include_token=True) raise Unauthorized() + + +# TODO: Add log action event for federation config changes +@resource("/v1/organization//robots//federation") +@path_param("orgname", "The name of the organization") +@path_param( + "robot_shortname", "The short name for the robot, without any user or organization prefix" +) +@related_user_resource(UserRobot) +class OrgRobotFederation(ApiResource): + + schemas = { + "CreateRobotFederation": CREATE_ROBOT_FEDERATION_SCHEMA, + } + + @require_scope(scopes.ORG_ADMIN) + def get(self, orgname, robot_shortname): + permission = AdministerOrganizationPermission(orgname) + if permission.can() or allow_if_superuser() or allow_if_global_readonly_superuser(): + robot_username = format_robot_username(orgname, robot_shortname) + robot = lookup_robot(robot_username) + return get_robot_federation_config(robot) + + raise Unauthorized() + + @require_scope(scopes.ORG_ADMIN) + @validate_json_request("CreateRobotFederation", optional=False) + def post(self, orgname, robot_shortname): + permission = AdministerOrganizationPermission(orgname) + if permission.can() or allow_if_superuser(): + fed_config = self._parse_federation_config(request) + + robot_username = format_robot_username(orgname, robot_shortname) + robot = lookup_robot(robot_username) + create_robot_federation_config(robot, fed_config) + log_action( + "create_robot_federation", + orgname, + {"config": fed_config, "robot": robot_shortname}, + ) + return fed_config + + raise Unauthorized() + + @require_scope(scopes.ORG_ADMIN) + def delete(self, orgname, robot_shortname): + permission = AdministerOrganizationPermission(orgname) + if permission.can() or allow_if_superuser(): + robot_username = format_robot_username(orgname, robot_shortname) + robot = lookup_robot(robot_username) + delete_robot_federation_config(robot) + log_action( + "delete_robot_federation", + orgname, + {"robot": robot_shortname}, + ) + return "", 204 + raise Unauthorized() + + def _parse_federation_config(self, request): + fed_config = list() + seen = set() + for item in request.json: + if not item: + raise request_error(message="Missing one or more required fields (issuer, subject)") + issuer = item.get("issuer") + subject = item.get("subject") + if not issuer or not subject: + raise request_error(message="Missing one or more required fields (issuer, subject)") + entry = {"issuer": issuer, "subject": subject} + + if f"{issuer}:{subject}" in seen: + raise request_error(message="Duplicate federation config entry") + + seen.add(f"{issuer}:{subject}") + fed_config.append(entry) + + return list(fed_config) diff --git a/endpoints/api/test/test_robot.py b/endpoints/api/test/test_robot.py index 46b09c7b40..e16efecbe7 100644 --- a/endpoints/api/test/test_robot.py +++ b/endpoints/api/test/test_robot.py @@ -1,10 +1,18 @@ import json +from unittest.mock import Mock import pytest +import requests from data import model from endpoints.api import api -from endpoints.api.robot import OrgRobot, OrgRobotList, UserRobot, UserRobotList +from endpoints.api.robot import ( + OrgRobot, + OrgRobotFederation, + OrgRobotList, + UserRobot, + UserRobotList, +) from endpoints.api.test.shared import conduct_api_call from endpoints.test.shared import client_with_identity from test.fixtures import * @@ -159,3 +167,97 @@ def test_duplicate_robot_creation(app): expected_code=400, ) assert resp.json["error_message"] == "Existing robot with name: buynlarge+coolrobot" + + +def test_robot_federation_create(app): + with client_with_identity("devtable", app) as cl: + # Create the robot with the specified body. + conduct_api_call( + cl, + OrgRobotFederation, + "POST", + { + "orgname": "buynlarge", + "robot_shortname": "coolrobot", + }, + [{"issuer": "issuer1", "subject": "subject1"}], + expected_code=200, + ) + + # Ensure the create succeeded. + resp = conduct_api_call( + cl, + OrgRobotFederation, + "GET", + { + "orgname": "buynlarge", + "robot_shortname": "coolrobot", + }, + expected_code=200, + ) + + assert len(resp.json) == 1 + assert resp.json[0].get("issuer") == "issuer1" + assert resp.json[0].get("subject") == "subject1" + + resp = conduct_api_call( + cl, + OrgRobotFederation, + "DELETE", + { + "orgname": "buynlarge", + "robot_shortname": "coolrobot", + }, + expected_code=204, + ) + + resp = conduct_api_call( + cl, + OrgRobotFederation, + "GET", + { + "orgname": "buynlarge", + "robot_shortname": "coolrobot", + }, + expected_code=200, + ) + + assert len(resp.json) == 0 + + +@pytest.mark.parametrize( + "fed_config, raises_error, error_message", + [ + ([{"issuer": "issuer1", "subject": "subject1"}], False, None), + ( + [{"bad_key": "issuer1", "subject": "subject1"}], + True, + "Missing one or more required fields", + ), + ( + [{"issuer": "issuer1", "subject": "subject1"}, {}], + True, + "Missing one or more required fields", + ), + ( + [ + {"issuer": "issuer1", "subject": "subject1"}, + {"issuer": "issuer2", "subject": "subject1"}, + {"issuer": "issuer1", "subject": "subject1"}, + ], + True, + "Duplicate federation config entry", + ), + ], +) +def test_parse_federation_config(app, fed_config, raises_error, error_message): + request = Mock(requests.Request) + request.json = fed_config + + with app.app_context(): + if raises_error: + with pytest.raises(Exception) as ex: + parsed = OrgRobotFederation()._parse_federation_config(request) + assert error_message in str(ex.value) + else: + parsed = OrgRobotFederation()._parse_federation_config(request) diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index d751cef242..16be903059 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -69,11 +69,11 @@ SECURITY_TESTS: List[ Tuple[ Type[ApiResource], - str, - Optional[Dict[str, Any]], - Optional[Dict[str, Any]], - Optional[str], - int, + str, # HTTP method + Optional[Dict[str, Any]], # Query params + Optional[Dict[str, Any]], # Body params + Optional[str], # Identity + int, # Expected HTTP status code ] ] = [ (AppTokens, "GET", {}, {}, None, 401), @@ -6824,6 +6824,30 @@ "reader", 403, ), + ( + OrgRobotFederation, + "GET", + {"orgname": "testfederatedorg", "robot_shortname": "testfederatedorg+testfederatedrobot"}, + None, + "reader", + 403, + ), + ( + OrgRobotFederation, + "POST", + {"orgname": "testfederatedorg", "robot_shortname": "testfederatedorg+testfederatedrobot"}, + {"subject": "testsubject", "issuer": "testissuer"}, + "testuser", + 400, + ), + ( + OrgRobotFederation, + "DELETE", + {"orgname": "testfederatedorg", "robot_shortname": "testfederatedorg+testfederatedrobot"}, + None, + "testuser", + 401, + ), ] diff --git a/endpoints/oauth/robot_identity_federation.py b/endpoints/oauth/robot_identity_federation.py new file mode 100644 index 0000000000..3e925eaf81 --- /dev/null +++ b/endpoints/oauth/robot_identity_federation.py @@ -0,0 +1,34 @@ +import logging + +from flask import Blueprint + +from app import instance_keys +from auth.decorators import process_basic_auth, process_federated_auth +from data import model +from data.database import RobotAccountToken +from data.model.user import generate_temp_robot_jwt_token, retrieve_robot_token +from util import request + +logger = logging.getLogger(__name__) +federation_bp = Blueprint("federation", __name__) + + +@federation_bp.route("/federation/robot/token") +@process_federated_auth +def auth_federated_robot_identity(auth_result): + """ + Authenticates the request using the robot identity federation mechanism. + and returns a robot temp token. + """ + # robot is authenticated, return an expiring robot token + if auth_result.missing or auth_result.error_message: + return { + "error": auth_result.error_message if auth_result.error_message else "missing auth" + }, 401 + + robot = auth_result.context.robot + assert robot + + # generate a JWT based robot token instead of static + token = generate_temp_robot_jwt_token(instance_keys) + return {"token": token} diff --git a/initdb.py b/initdb.py index 0862c721c1..fddc223f06 100644 --- a/initdb.py +++ b/initdb.py @@ -356,6 +356,11 @@ def initialize_database(): LogEntryKind.create(name="create_robot") LogEntryKind.create(name="delete_robot") + LogEntryKind.create(name="create_robot_federation") + LogEntryKind.create(name="delete_robot_federation") + + LogEntryKind.create(name="federated_robot_token_exchange") + LogEntryKind.create(name="create_repo") LogEntryKind.create(name="push_repo") LogEntryKind.create(name="push_repo_failed") diff --git a/oauth/login_utils.py b/oauth/login_utils.py index 03666437d9..ad02aa5a8b 100644 --- a/oauth/login_utils.py +++ b/oauth/login_utils.py @@ -1,5 +1,3 @@ -import base64 -import json import logging from collections import namedtuple @@ -20,16 +18,6 @@ logger = logging.getLogger(__name__) -def is_jwt(token): - try: - headers = jwt.get_unverified_header(token) - return headers.get("typ", "").lower() == "jwt" - except jwt.exceptions.DecodeError: - pass - - return False - - def get_jwt_issuer(token): """ Extract the issuer from the JWT token. diff --git a/oauth/oidc.py b/oauth/oidc.py index 754d37844b..ecc34721c5 100644 --- a/oauth/oidc.py +++ b/oauth/oidc.py @@ -2,6 +2,7 @@ import logging import time import urllib.parse +from posixpath import join import jwt from authlib.jose import JsonWebKey, KeySet @@ -225,7 +226,7 @@ def _load_oidc_config_via_discovery(self, is_debugging): if not oidc_server.startswith("https://") and not is_debugging: raise DiscoveryFailureException("OIDC server must be accessed over SSL") - discovery_url = urllib.parse.urljoin(oidc_server, OIDC_WELLKNOWN) + discovery_url = join(oidc_server, OIDC_WELLKNOWN) discovery = self._http_client.get(discovery_url, timeout=5, verify=is_debugging is False) if discovery.status_code // 100 != 2: logger.debug( diff --git a/tox.ini b/tox.ini index 534288603d..2c608c1af6 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,8 @@ skipsdist = True norecursedirs = node_modules testpaths = ./ python_files = **/test/test*.py +log_cli = 0 +log_cli_level = INFO [testenv] deps = diff --git a/util/security/federated_robot_auth.py b/util/security/federated_robot_auth.py new file mode 100644 index 0000000000..203222876e --- /dev/null +++ b/util/security/federated_robot_auth.py @@ -0,0 +1,116 @@ +import json +import logging + +from jwt import InvalidTokenError + +from app import app +from auth.basic import _parse_basic_auth_header +from auth.log import log_action +from auth.validateresult import AuthKind, ValidateResult +from data.database import FederatedLogin +from data.model import InvalidRobotCredentialException +from data.model.user import lookup_robot +from oauth.login_utils import get_jwt_issuer +from oauth.oidc import OIDCLoginService +from util.names import parse_robot_username + +logger = logging.getLogger(__name__) + + +def validate_federated_auth(auth_header): + """ + Validates the specified federated auth header, returning whether its credentials point to a valid + user or token. + """ + if not auth_header: + return ValidateResult(AuthKind.federated, missing=True, error_message="No auth header") + + logger.debug("Attempt to process federated auth header") + + # Parse the federated auth header. + assert isinstance(auth_header, str) + credentials, err = _parse_basic_auth_header(auth_header) + if err is not None: + logger.debug("Got invalid federated auth header: %s", auth_header) + return ValidateResult(AuthKind.federated, missing=True, error_message=err) + + auth_username, federated_token = credentials + + is_robot = parse_robot_username(auth_username) + if not is_robot: + logger.debug( + f"Federated auth is only supported for robots. got invalid federated auth header: {auth_header}" + ) + return ValidateResult(AuthKind.federated, missing=True, error_message="Invalid robot") + + # find out if the robot is federated + # get the issuer from the DB config + # validate the token + robot = lookup_robot(auth_username) + assert robot.robot + + result = verify_federated_robot_jwt_token(robot, federated_token) + return result.with_kind(AuthKind.federated) + + +def verify_federated_robot_jwt_token(robot, token): + # The token is a JWT token from the external OIDC provider + # We always have an entry in the federatedlogin table for each robot account + federated_robot = FederatedLogin.select().where(FederatedLogin.user == robot).get() + assert federated_robot + + try: + metadata = json.loads(federated_robot.metadata_json) + except Exception as e: + logger.debug("Error parsing federated login metadata: %s", e) + raise InvalidRobotCredentialException("Robot does not have federated login configured") + + # check if robot has federated login config + token_issuer = get_jwt_issuer(token) + if not token_issuer: + raise InvalidRobotCredentialException("Token does not contain issuer") + + fed_config = metadata.get("federation_config", []) + if not fed_config: + raise InvalidRobotCredentialException("Robot does not have federated login configured") + + matched_subs = [] + for item in fed_config: + if item.get("issuer") == token_issuer: + matched_subs.append(item.get("subject")) + + if not matched_subs: + raise InvalidRobotCredentialException( + f"issuer {token_issuer} not configured for this robot" + ) + + # verify the token + service_config = {"quayrobot": {"OIDC_SERVER": token_issuer}} + service = OIDCLoginService(service_config, "quayrobot", client=app.config["HTTPCLIENT"]) + + # throws an exception if we cannot decode/verify the token + options = {"verify_aud": False, "verify_nbf": False} + try: + decoded_token = service.decode_user_jwt(token, options=options) + except InvalidTokenError as e: + raise InvalidRobotCredentialException(f"Invalid token: {e}") + + assert decoded_token + # check if the token is for the robot + + if decoded_token.get("sub") not in matched_subs: + raise InvalidRobotCredentialException("Token does not match robot") + + namespace, robot_name = parse_robot_username(robot.username) + + log_action( + "federated_robot_token_exchange", + namespace, + { + "subject": decoded_token.get("sub"), + "issuer": decoded_token.get("iss"), + "robot": robot_name, + }, + ) + + return ValidateResult(AuthKind.credentials, robot=robot) diff --git a/util/security/instancekeys.py b/util/security/instancekeys.py index 88bf2cf57a..47ba7f1a77 100644 --- a/util/security/instancekeys.py +++ b/util/security/instancekeys.py @@ -1,9 +1,13 @@ +import logging + from cachetools.func import lru_cache from data import model from util.expiresdict import ExpiresDict, ExpiresEntry from util.security import jwtutil +logger = logging.getLogger(__name__) + class CachingKey(object): def __init__(self, service_key): diff --git a/util/security/jwtutil.py b/util/security/jwtutil.py index d148d1e16b..5b79431cff 100644 --- a/util/security/jwtutil.py +++ b/util/security/jwtutil.py @@ -1,3 +1,4 @@ +import logging import re from calendar import timegm from datetime import datetime, timedelta @@ -6,7 +7,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers -from jwt import PyJWT +from jwt import PyJWT, get_unverified_header from jwt.exceptions import ( DecodeError, ExpiredSignatureError, @@ -19,6 +20,8 @@ MissingRequiredClaimError, ) +logger = logging.getLogger(__name__) + # TOKEN_REGEX defines a regular expression for matching JWT bearer tokens. TOKEN_REGEX = re.compile(r"\ABearer (([a-zA-Z0-9+\-_/]+\.)+[a-zA-Z0-9+\-_/]+)\Z") @@ -140,3 +143,13 @@ def jwk_dict_to_public_key(jwk_dict): ).public_key(default_backend()) raise Exception("Unsupported kind of JWK: %s", str(type(jwk))) + + +def is_jwt(token): + try: + headers = get_unverified_header(token) + return headers.get("typ", "").lower() == "jwt" + except DecodeError: + pass + + return False diff --git a/util/security/registry_jwt.py b/util/security/registry_jwt.py index e0c1de8477..346d9a7c85 100644 --- a/util/security/registry_jwt.py +++ b/util/security/registry_jwt.py @@ -90,15 +90,13 @@ def decode_bearer_token(bearer_token, instance_keys, config): kid = headers.get("kid", None) if kid is None: - logger.error("Missing kid header on encoded JWT: %s", bearer_token) + logger.error("Missing kid header on encoded JWT") raise InvalidBearerTokenException("Missing kid header") # Find the matching public key. public_key = instance_keys.get_service_key_public_key(kid) if public_key is None: - logger.error( - "Could not find requested service key %s with encoded JWT: %s", kid, bearer_token - ) + logger.error("Could not find requested service key %s with encoded JWT", kid) raise InvalidBearerTokenException("Unknown service key") # Load the JWT returned. diff --git a/web.py b/web.py index f0e102b8b8..e8decebf0a 100644 --- a/web.py +++ b/web.py @@ -5,6 +5,7 @@ from endpoints.gitlabtrigger import gitlabtrigger from endpoints.keyserver import key_server from endpoints.oauth.login import oauthlogin +from endpoints.oauth.robot_identity_federation import federation_bp from endpoints.realtime import realtime from endpoints.web import web from endpoints.webhooks import webhooks @@ -14,6 +15,7 @@ application.register_blueprint(githubtrigger, url_prefix="/oauth2") application.register_blueprint(gitlabtrigger, url_prefix="/oauth2") application.register_blueprint(oauthlogin, url_prefix="/oauth2") +application.register_blueprint(federation_bp, url_prefix="/oauth2") application.register_blueprint(bitbuckettrigger, url_prefix="/oauth1") application.register_blueprint(api_bp, url_prefix="/api") application.register_blueprint(webhooks, url_prefix="/webhooks") diff --git a/web/src/components/modals/RobotFederationModal.tsx b/web/src/components/modals/RobotFederationModal.tsx new file mode 100644 index 0000000000..46855beb1b --- /dev/null +++ b/web/src/components/modals/RobotFederationModal.tsx @@ -0,0 +1,255 @@ +import {IRobot} from 'src/resources/RobotsResource'; +import { + ActionGroup, + Button, + Flex, + FlexItem, + Form, + FormFieldGroupExpandable, + FormFieldGroupHeader, + FormGroup, + Spinner, + TextInput, +} from '@patternfly/react-core'; +import {PlusIcon, TrashIcon} from '@patternfly/react-icons'; +import React, {useEffect, useState} from 'react'; +import DisplayModal from './robotAccountWizard/DisplayModal'; +import {useRobotFederation} from 'src/hooks/useRobotFederation'; +import {useAlerts} from 'src/hooks/UseAlerts'; +import {AlertVariant} from 'src/atoms/AlertState'; + +function RobotFederationForm(props: RobotFederationFormProps) { + const [federationFormState, setFederationFormState] = useState< + RobotFederationFormEntryProps[] + >([]); + + const alerts = useAlerts(); + + const {robotFederationConfig, loading, fetchError, setRobotFederationConfig} = + useRobotFederation({ + namespace: props.namespace, + robotName: props.robotAccount.name, + onSuccess: (result) => { + setFederationFormState( + result.map((config) => ({...config, isExpanded: false})), + ); + alerts.addAlert({ + title: 'Robot federation config saved', + variant: AlertVariant.Success, + }); + }, + onError: (e) => { + alerts.addAlert({ + title: e.error_message || 'Error saving federation config', + variant: AlertVariant.Failure, + }); + }, + }); + + useEffect(() => { + if (robotFederationConfig) { + setFederationFormState( + robotFederationConfig.map((config) => ({...config, isExpanded: false})), + ); + } + }, [robotFederationConfig]); + + if (loading) { + return ; + } + + if (fetchError) { + return
Error fetching federation config
; + } + + const addFederationConfigEntry = () => { + setFederationFormState((prev) => { + return [ + ...prev, + { + issuer: '', + subject: '', + isExpanded: true, + }, + ]; + }); + }; + + const updateFederationConfigEntry = ( + index: number, + issuer: string, + subject: string, + ) => { + setFederationFormState((prev) => { + return prev.map((config, i) => { + if (i === index) { + return { + issuer, + subject, + isExpanded: config.isExpanded, + }; + } + return config; + }); + }); + }; + + const removeFederationConfigEntry = (index: number) => { + setFederationFormState((prev) => { + return prev.filter((_, i) => i !== index); + }); + }; + + const onFormSave = () => { + setRobotFederationConfig({ + namespace: props.namespace, + robotName: props.robotAccount.name, + config: federationFormState, + }); + }; + + const onFormClose = () => { + props.onClose(); + }; + + return ( + <> +
+ {federationFormState.map((config, index) => { + return ( + + ); + })} + + + + {federationFormState.length == 0 && ( + +
No federation configured, add using the plus button
+
+ )} + + + +
+
+ + + + + + + ); +} + +function RobotFederationFormEntry(props: RobotFederationFormEntryProps) { + return ( + { + props.onRemove(props.index); + }} + variant="danger" + > + + + } + /> + } + > + + { + props.onUpdate(props.index, value, props.subject); + }} + /> + + + { + props.onUpdate(props.index, props.issuer, value); + }} + /> + + + ); +} + +export function RobotFederationModal(props: RobotFederationModalProps) { + return ( + { + props.setIsModalOpen(false); + }} + onSave={() => { + props.setIsModalOpen(false); + }} + /> + } + showSave={false} + showFooter={false} + /> + ); +} + +interface RobotFederationModalProps { + robotAccount: IRobot; + namespace: string; + isModalOpen: boolean; + setIsModalOpen: (modalState: boolean) => void; +} + +interface RobotFederationFormProps { + robotAccount: IRobot; + namespace: string; + onClose: () => void; + onSave: () => void; +} + +interface RobotFederationFormEntryProps { + issuer: string; + subject: string; + index?: number; + isExpanded?: boolean; + onRemove?: (index: number) => void; + onUpdate?: (index: number, issuer: string, subject: string) => void; +} diff --git a/web/src/hooks/useRobotFederation.ts b/web/src/hooks/useRobotFederation.ts new file mode 100644 index 0000000000..dfdce7c18c --- /dev/null +++ b/web/src/hooks/useRobotFederation.ts @@ -0,0 +1,59 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import { + createRobotFederationConfig, + fetchRobotFederationConfig, + IRobotFederationConfig, +} from 'src/resources/RobotsResource'; + +export function useRobotFederation({namespace, robotName, onSuccess, onError}) { + const queryClient = useQueryClient(); + + const { + data: robotFederationConfig, + isLoading, + error, + } = useQuery( + ['Namespace', namespace, 'robot', robotName, 'federation'], + ({signal}) => fetchRobotFederationConfig(namespace, robotName, signal), + ); + + const robotFederationMutator = useMutation( + async (args: CreateRobotFederationParams) => { + return createRobotFederationConfig( + args.namespace, + args.robotName, + args.config, + ); + }, + { + onSuccess: (result: IRobotFederationConfig[]) => { + queryClient.invalidateQueries([ + 'Namespace', + namespace, + 'robot', + robotName, + 'federation', + ]); + onSuccess(result); + }, + onError: (createError) => { + onError(createError?.response?.data); + }, + }, + ); + + return { + robotFederationConfig, + loading: isLoading, + fetchError: error, + + // mutations + setRobotFederationConfig: robotFederationMutator.mutate, + }; +} + +interface CreateRobotFederationParams { + namespace: string; + robotName: string; + config: IRobotFederationConfig[]; +} diff --git a/web/src/resources/RobotsResource.ts b/web/src/resources/RobotsResource.ts index 970fb1805f..bbf3717b9a 100644 --- a/web/src/resources/RobotsResource.ts +++ b/web/src/resources/RobotsResource.ts @@ -48,6 +48,11 @@ export interface IRobotToken { unstructured_metadata: object; } +export interface IRobotFederationConfig { + issuer: string; + subject: string; +} + export async function fetchAllRobots(orgnames: string[], signal: AbortSignal) { return await Promise.all( orgnames.map((org) => fetchRobotsForNamespace(org, false, signal)), @@ -305,3 +310,34 @@ export async function regenerateRobotToken( assertHttpCode(response.status, 200); return response.data; } + +export async function fetchRobotFederationConfig( + orgName: string, + robotName: string, + signal: AbortSignal, +) { + const robot = robotName.replace(orgName + '+', ''); + const userOrOrgPath = `organization/${orgName}`; + const getRobotFederationConfigUrl = `/api/v1/${userOrOrgPath}/robots/${robot}/federation`; + const response: AxiosResponse = await axios.get(getRobotFederationConfigUrl, { + signal, + }); + assertHttpCode(response.status, 200); + return response.data; +} + +export async function createRobotFederationConfig( + orgName: string, + robotName: string, + federationConfig: IRobotFederationConfig[], +) { + const robot = robotName.replace(orgName + '+', ''); + const userOrOrgPath = `organization/${orgName}`; + const createRobotFederationConfigUrl = `/api/v1/${userOrOrgPath}/robots/${robot}/federation`; + const response: AxiosResponse = await axios.post( + createRobotFederationConfigUrl, + federationConfig, + ); + assertHttpCode(response.status, 200); + return response.data; +} diff --git a/web/src/routes/RepositoriesList/RobotAccountKebab.tsx b/web/src/routes/RepositoriesList/RobotAccountKebab.tsx index 627a344bc7..3bb2f7c2c0 100644 --- a/web/src/routes/RepositoriesList/RobotAccountKebab.tsx +++ b/web/src/routes/RepositoriesList/RobotAccountKebab.tsx @@ -29,6 +29,10 @@ export default function RobotAccountKebab(props: RobotAccountKebabProps) { props.onSetRepoPermsClick(props.robotAccount, props.robotAccountRepos); }; + const onSetRobotFederation = () => { + props.onSetRobotFederationClick(props.robotAccount); + }; + return ( <> setIsOpen(isOpen)} shouldFocusToggleOnSelect + popperProps={{ + enableFlip: true, + position: 'right', + }} > onSetRepoPerms()} id={`${props.robotAccount.name}-set-repo-perms-btn`} > - {props.deleteKebabIsOpen ? props.deleteModal() : null} Set repository permissions - + onSetRobotFederation()} + id={`${props.robotAccount.name}-set-robot-federation-btn`} + > + Set robot federation + onDelete()} className="red-color" id={`${props.robotAccount.name}-del-btn`} > - {props.deleteKebabIsOpen ? props.deleteModal() : null} Delete @@ -81,5 +92,6 @@ interface RobotAccountKebabProps { setDeleteModalOpen: (open) => void; setSelectedRobotAccount: (robotAccount) => void; onSetRepoPermsClick: (robotAccount, repos) => void; + onSetRobotFederationClick: (robotAccount) => void; robotAccountRepos: any[]; } diff --git a/web/src/routes/RepositoriesList/RobotAccountsList.tsx b/web/src/routes/RepositoriesList/RobotAccountsList.tsx index ac4d56c3ae..88e9642d3d 100644 --- a/web/src/routes/RepositoriesList/RobotAccountsList.tsx +++ b/web/src/routes/RepositoriesList/RobotAccountsList.tsx @@ -51,6 +51,7 @@ import RobotTokensModal from 'src/components/modals/RobotTokensModal'; import {SearchState} from 'src/components/toolbar/SearchTypes'; import {AlertVariant} from 'src/atoms/AlertState'; import {useAlerts} from 'src/hooks/UseAlerts'; +import {RobotFederationModal} from 'src/components/modals/RobotFederationModal'; export const RepoPermissionDropdownItems = [ { @@ -100,12 +101,16 @@ export default function RobotAccountsList(props: RobotAccountsListProps) { const [selectedRepoPerms, setSelectedRepoPerms] = useRecoilState( selectedReposPermissionState, ); + const [prevRepoPerms, setPrevRepoPerms] = useState({}); const [showRepoModalSave, setShowRepoModalSave] = useState(false); const [newRepoPerms, setNewRepoPerms] = useState({}); const [err, setErr] = useState(); const [errTitle, setErrTitle] = useState(); const robotPermissionsPlaceholder = useRef(null); + const [isRobotFederationModalOpen, setRobotFederationModalOpen] = + useState(false); + const {addAlert} = useAlerts(); const {robotAccountsForOrg, page, perPage, setPage, setPerPage} = @@ -340,6 +345,11 @@ export default function RobotAccountsList(props: RobotAccountsListProps) { setReposModalOpen(true); }; + const robotFederationModal = (robotAccount) => { + setRobotForModalView(robotAccount); + setRobotFederationModalOpen(true); + }; + const fetchTeamsModal = (items) => { const filteredItems = teams.filter((team) => items.some((item) => team.name === item.name), @@ -585,6 +595,12 @@ export default function RobotAccountsList(props: RobotAccountsListProps) { } showFooter={true} /> + @@ -659,6 +675,7 @@ export default function RobotAccountsList(props: RobotAccountsListProps) { setSelectedRobotAccount={setRobotForDeletion} onSetRepoPermsClick={fetchReposModal} robotAccountRepos={robotAccount.repositories} + onSetRobotFederationClick={robotFederationModal} /> diff --git a/web/src/routes/UsageLogs/UsageLogsGraph.tsx b/web/src/routes/UsageLogs/UsageLogsGraph.tsx index db9f66ce9a..a4ed20e1d8 100644 --- a/web/src/routes/UsageLogs/UsageLogsGraph.tsx +++ b/web/src/routes/UsageLogs/UsageLogsGraph.tsx @@ -46,7 +46,9 @@ export default function UsageLogsGraph(props: UsageLogsGraphProps) { }, ); + // tslint:disable-next-line:curly if (loadingAggregateLogs) return ; + // tslint:disable-next-line:curly if (errorFetchingLogs) return ; let maxRange = 0; @@ -61,6 +63,7 @@ export default function UsageLogsGraph(props: UsageLogsGraphProps) { x: new Date(log.datetime), y: log.count, }); + // tslint:disable-next-line:curly if (log.count > maxRange) maxRange = log.count; }); return logData; @@ -88,6 +91,7 @@ export default function UsageLogsGraph(props: UsageLogsGraphProps) { ); + // tslint:disable-next-line:curly } else return ( diff --git a/web/webpack.dev.js b/web/webpack.dev.js index 7b815c2ef2..aa15f8e308 100644 --- a/web/webpack.dev.js +++ b/web/webpack.dev.js @@ -24,6 +24,10 @@ module.exports = merge(common('development'), { target: 'http://localhost:8080', logLevel: 'debug', }, + '/config': { + target: 'http://localhost:8080', + logLevel: 'debug', + }, }, }, module: {