1
0
mirror of https://github.com/quay/quay.git synced 2025-04-18 10:44:06 +03:00

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.
This commit is contained in:
Syed Ahmed 2024-09-24 11:32:38 -04:00 committed by GitHub
parent 3181dfc93e
commit e9161cb3ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1191 additions and 43 deletions

1
app.py
View File

@ -252,7 +252,6 @@ billing = Billing(app)
sentry = Sentry(app)
build_logs = BuildLogs(app)
userevents = UserEventsBuilderModule(app)
instance_keys = InstanceKeys(app)
label_validator = LabelValidator(app)
build_canceller = BuildCanceller(app)

View File

@ -2,9 +2,10 @@ import logging
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"):

View File

@ -10,6 +10,7 @@ from auth.oauth import validate_bearer_auth
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 @@ process_auth = _auth_decorator(handlers=[validate_signed_grant, validate_basic_a
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):

View File

@ -14,9 +14,9 @@ from oauth.login_utils import (
_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__)

View File

@ -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)

View File

@ -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

View File

@ -10,6 +10,7 @@ class AuthKind(Enum):
signed_grant = "signed_grant"
credentials = "credentials"
ssojwt = "ssojwt"
federated = "federated"
def __str__(self):
return "%s" % self.value

View File

@ -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")
)
)

View File

@ -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.model.user import (
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):

View File

@ -8,6 +8,7 @@ import bcrypt
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 data.text import prefix_search
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 @@ from util.validation import (
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)

View File

@ -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 @@ from auth.permissions import (
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_SCHEMA = {
},
}
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 @@ class RegenerateOrgRobot(ApiResource):
return robot.to_dict(include_token=True)
raise Unauthorized()
# TODO: Add log action event for federation config changes
@resource("/v1/organization/<orgname>/robots/<robot_shortname>/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)

View File

@ -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)

View File

@ -69,11 +69,11 @@ EXPORTLOGS_PARAMS = {"callback_url": "http://foo"}
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 @@ SECURITY_TESTS: List[
"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,
),
]

View File

@ -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}

View File

@ -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")

View File

@ -1,5 +1,3 @@
import base64
import json
import logging
from collections import namedtuple
@ -20,16 +18,6 @@ OAuthResult = namedtuple(
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.

View File

@ -2,6 +2,7 @@ import json
import logging
import time
import urllib.parse
from posixpath import join
import jwt
from authlib.jose import JsonWebKey, KeySet
@ -225,7 +226,7 @@ class OIDCLoginService(OAuthService):
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(

View File

@ -6,6 +6,8 @@ skipsdist = True
norecursedirs = node_modules
testpaths = ./
python_files = **/test/test*.py
log_cli = 0
log_cli_level = INFO
[testenv]
deps =

View File

@ -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)

View File

@ -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):

View File

@ -1,3 +1,4 @@
import logging
import re
from calendar import timegm
from datetime import datetime, timedelta
@ -6,7 +7,7 @@ from authlib.jose import ECKey, JsonWebKey, RSAKey
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 @@ from jwt.exceptions import (
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

View File

@ -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.

2
web.py
View File

@ -5,6 +5,7 @@ from endpoints.githubtrigger import githubtrigger
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(web)
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")

View File

@ -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 <Spinner size="md" />;
}
if (fetchError) {
return <div>Error fetching federation config</div>;
}
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 (
<>
<Form>
{federationFormState.map((config, index) => {
return (
<RobotFederationFormEntry
issuer={config.issuer}
subject={config.subject}
index={index}
key={index}
isExpanded={config.isExpanded}
onRemove={removeFederationConfigEntry}
onUpdate={updateFederationConfigEntry}
/>
);
})}
<FormGroup>
<Flex>
{federationFormState.length == 0 && (
<FlexItem>
<div> No federation configured, add using the plus button </div>
</FlexItem>
)}
<FlexItem align={{default: 'alignRight'}}>
<Button
onClick={() => {
addFederationConfigEntry();
}}
>
<PlusIcon />
</Button>
</FlexItem>
</Flex>
</FormGroup>
<ActionGroup>
<Button variant="primary" onClick={onFormSave}>
Save
</Button>
<Button variant="link" onClick={onFormClose}>
Close
</Button>
</ActionGroup>
</Form>
</>
);
}
function RobotFederationFormEntry(props: RobotFederationFormEntryProps) {
return (
<FormFieldGroupExpandable
isExpanded={props.isExpanded}
header={
<FormFieldGroupHeader
titleText={{
text: `${props.issuer} : ${props.subject}`,
id: `${props.index}-issuer-url`,
}}
actions={
<Button
onClick={() => {
props.onRemove(props.index);
}}
variant="danger"
>
<TrashIcon />
</Button>
}
/>
}
>
<FormGroup label={'Issuer URL'} isRequired>
<TextInput
value={props.issuer}
type="text"
isRequired
onChange={(event, value) => {
props.onUpdate(props.index, value, props.subject);
}}
/>
</FormGroup>
<FormGroup label={'Subject'} isRequired>
<TextInput
value={props.subject}
type="text"
isRequired
onChange={(event, value) => {
props.onUpdate(props.index, props.issuer, value);
}}
/>
</FormGroup>
</FormFieldGroupExpandable>
);
}
export function RobotFederationModal(props: RobotFederationModalProps) {
return (
<DisplayModal
isModalOpen={props.isModalOpen}
setIsModalOpen={props.setIsModalOpen}
title={`Robot identity federation configuration for ${props.robotAccount.name}`}
Component={
<RobotFederationForm
robotAccount={props.robotAccount}
namespace={props.namespace}
onClose={() => {
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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -29,6 +29,10 @@ export default function RobotAccountKebab(props: RobotAccountKebabProps) {
props.onSetRepoPermsClick(props.robotAccount, props.robotAccountRepos);
};
const onSetRobotFederation = () => {
props.onSetRobotFederationClick(props.robotAccount);
};
return (
<>
<Dropdown
@ -48,22 +52,29 @@ export default function RobotAccountKebab(props: RobotAccountKebabProps) {
isOpen={isOpen}
onOpenChange={(isOpen) => setIsOpen(isOpen)}
shouldFocusToggleOnSelect
popperProps={{
enableFlip: true,
position: 'right',
}}
>
<DropdownList>
<DropdownItem
onClick={() => onSetRepoPerms()}
id={`${props.robotAccount.name}-set-repo-perms-btn`}
>
{props.deleteKebabIsOpen ? props.deleteModal() : null}
Set repository permissions
</DropdownItem>
<DropdownItem
onClick={() => onSetRobotFederation()}
id={`${props.robotAccount.name}-set-robot-federation-btn`}
>
Set robot federation
</DropdownItem>
<DropdownItem
onClick={() => onDelete()}
className="red-color"
id={`${props.robotAccount.name}-del-btn`}
>
{props.deleteKebabIsOpen ? props.deleteModal() : null}
Delete
</DropdownItem>
</DropdownList>
@ -81,5 +92,6 @@ interface RobotAccountKebabProps {
setDeleteModalOpen: (open) => void;
setSelectedRobotAccount: (robotAccount) => void;
onSetRepoPermsClick: (robotAccount, repos) => void;
onSetRobotFederationClick: (robotAccount) => void;
robotAccountRepos: any[];
}

View File

@ -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<string[]>();
const [errTitle, setErrTitle] = useState<string>();
const robotPermissionsPlaceholder = useRef(null);
const [isRobotFederationModalOpen, setRobotFederationModalOpen] =
useState<boolean>(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}
/>
<RobotFederationModal
robotAccount={robotForModalView}
isModalOpen={isRobotFederationModalOpen}
setIsModalOpen={setRobotFederationModalOpen}
namespace={props.organizationName}
/>
<Table aria-label="Expandable table" variant="compact">
<Thead>
<Tr>
@ -659,6 +675,7 @@ export default function RobotAccountsList(props: RobotAccountsListProps) {
setSelectedRobotAccount={setRobotForDeletion}
onSetRepoPermsClick={fetchReposModal}
robotAccountRepos={robotAccount.repositories}
onSetRobotFederationClick={robotFederationModal}
/>
</Td>
</Tr>

View File

@ -46,7 +46,9 @@ export default function UsageLogsGraph(props: UsageLogsGraphProps) {
},
);
// tslint:disable-next-line:curly
if (loadingAggregateLogs) return <Spinner />;
// tslint:disable-next-line:curly
if (errorFetchingLogs) return <RequestError message="Unable to get logs" />;
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) {
</FlexItem>
</Flex>
);
// tslint:disable-next-line:curly
} else
return (
<Flex grow={{default: 'grow'}}>

View File

@ -24,6 +24,10 @@ module.exports = merge(common('development'), {
target: 'http://localhost:8080',
logLevel: 'debug',
},
'/config': {
target: 'http://localhost:8080',
logLevel: 'debug',
},
},
},
module: {