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:
parent
3181dfc93e
commit
e9161cb3ae
1
app.py
1
app.py
@ -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)
|
||||
|
||||
|
@ -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"):
|
||||
|
@ -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):
|
||||
|
@ -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__)
|
||||
|
||||
|
116
auth/test/mock_oidc_server.py
Normal file
116
auth/test/mock_oidc_server.py
Normal 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)
|
110
auth/test/test_federated_robot_auth.py
Normal file
110
auth/test/test_federated_robot_auth.py
Normal 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
|
@ -10,6 +10,7 @@ class AuthKind(Enum):
|
||||
signed_grant = "signed_grant"
|
||||
credentials = "credentials"
|
||||
ssojwt = "ssojwt"
|
||||
federated = "federated"
|
||||
|
||||
def __str__(self):
|
||||
return "%s" % self.value
|
||||
|
@ -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")
|
||||
)
|
||||
)
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
34
endpoints/oauth/robot_identity_federation.py
Normal file
34
endpoints/oauth/robot_identity_federation.py
Normal 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}
|
@ -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")
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
|
2
tox.ini
2
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 =
|
||||
|
116
util/security/federated_robot_auth.py
Normal file
116
util/security/federated_robot_auth.py
Normal 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)
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
2
web.py
@ -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")
|
||||
|
255
web/src/components/modals/RobotFederationModal.tsx
Normal file
255
web/src/components/modals/RobotFederationModal.tsx
Normal 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;
|
||||
}
|
59
web/src/hooks/useRobotFederation.ts
Normal file
59
web/src/hooks/useRobotFederation.ts
Normal 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[];
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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'}}>
|
||||
|
@ -24,6 +24,10 @@ module.exports = merge(common('development'), {
|
||||
target: 'http://localhost:8080',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
'/config': {
|
||||
target: 'http://localhost:8080',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
},
|
||||
},
|
||||
module: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user