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

oauth: allowing to assign token to user (PROJQUAY-7074) (#2869)

Allow organization administrators to assign Oauth token to another user.
This commit is contained in:
Brandon Caton 2024-06-25 09:23:51 -04:00 committed by GitHub
parent bc06a3ef36
commit e4f05583c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1331 additions and 41 deletions

View File

@ -51,6 +51,7 @@ CLIENT_WHITELIST = [
"UI_V2_FEEDBACK_FORM",
"TERMS_OF_SERVICE_URL",
"UI_DELAY_AFTER_WRITE_SECONDS",
"FEATURE_ASSIGN_OAUTH_TOKEN",
]
@ -887,3 +888,5 @@ class DefaultConfig(ImmutableConfig):
# whitelist for ROBOTS_DISALLOW to grant access/usage for mirroring
ROBOTS_WHITELIST: Optional[List[str]] = []
FEATURE_ASSIGN_OAUTH_TOKEN = True

View File

@ -751,6 +751,7 @@ class User(BaseModel):
NamespaceAutoPrunePolicy,
AutoPruneTaskStatus,
RepositoryAutoPrunePolicy,
OauthAssignedToken,
}
| appr_classes
| v22_classes
@ -2035,6 +2036,19 @@ class RepositoryAutoPrunePolicy(BaseModel):
policy = JSONField(null=False, default={})
class OauthAssignedToken(BaseModel):
uuid = CharField(default=uuid_generator, max_length=36, index=True, unique=True, null=False)
assigned_user = QuayUserField(index=True, unique=False, null=False)
application = ForeignKeyField(OAuthApplication, index=True, null=False, unique=False)
redirect_uri = CharField(max_length=255, null=True)
scope = CharField(max_length=255, null=False)
response_type = CharField(max_length=255, null=True)
class Meta:
database = db
read_only_config = read_only_config
# Defines a map from full-length index names to the legacy names used in our code
# to meet length restrictions.
LEGACY_INDEX_MAP = {

View File

@ -0,0 +1,60 @@
"""add oauth assigned token table
Revision ID: 0988213e0885
Revises: 0cdd1f27a450
Create Date: 2024-05-15 09:09:58.088599
"""
# revision identifiers, used by Alembic.
revision = "0988213e0885"
down_revision = "0cdd1f27a450"
import sqlalchemy as sa
def upgrade(op, tables, tester):
op.create_table(
"oauthassignedtoken",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", sa.String(length=255), nullable=False),
sa.Column("assigned_user_id", sa.Integer(), nullable=False),
sa.Column("application_id", sa.Integer(), nullable=False),
sa.Column("redirect_uri", sa.String(length=255), nullable=True),
sa.Column("scope", sa.String(length=255), nullable=False),
sa.Column("response_type", sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(
["application_id"],
["oauthapplication.id"],
name=op.f("fk_oauthassignedtoken_application_oauthapplication"),
),
sa.ForeignKeyConstraint(
["assigned_user_id"],
["user.id"],
name=op.f("fk_oauthassignedtoken_assigned_user_user"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_oauthassignedtoken")),
)
op.create_index("oauthassignedtoken_uuid", "oauthassignedtoken", ["uuid"], unique=True)
op.create_index(
"oauthassignedtoken_application_id", "oauthassignedtoken", ["application_id"], unique=False
)
op.create_index(
"oauthassignedtoken_assigned_user", "oauthassignedtoken", ["assigned_user_id"], unique=False
)
op.bulk_insert(
tables.notificationkind,
[
{"name": "assigned_authorization"},
],
)
def downgrade(op, tables, tester):
op.drop_table("oauthassignedtoken")
op.execute(
tables.notificationkind.delete().where(
tables.notificationkind.c.name == op.inline_literal("assigned_authorization")
)
)

View File

@ -8,12 +8,14 @@ from auth import scopes
from data.database import (
OAuthAccessToken,
OAuthApplication,
OauthAssignedToken,
OAuthAuthorizationCode,
User,
random_string_generator,
)
from data.fields import Credential, DecryptedValue
from data.model import config, user
from data.model import config, db_transaction, notification, user
from data.model.organization import is_org_admin
from oauth import utils
from oauth.provider import AuthorizationProvider
from util import get_app_url
@ -189,15 +191,20 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
return self._make_redirect_error_response(redirect_uri, "authorization_denied")
def get_token_response(self, response_type, client_id, redirect_uri, **params):
def get_token_response(
self, response_type, client_id, redirect_uri, assignment_uuid=None, **params
):
# Ensure proper response_type
if response_type != "token":
err = "unsupported_response_type"
return self._make_redirect_error_response(redirect_uri, err)
# Check for a valid client ID.
is_valid_client_id = self.validate_client_id(client_id)
if not is_valid_client_id:
oauth_application = self.get_application_for_client_id(client_id)
if oauth_application is None or not self.is_org_admin_or_has_token_assignment(
oauth_application.organization, assignment_uuid
):
err = "unauthorized_client"
return self._make_redirect_error_response(redirect_uri, err)
@ -239,6 +246,13 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
data=data,
)
# check for assignment_id and delete it if it exists
if assignment_uuid is not None:
user_obj = self.get_authorized_user()
assign = get_token_assignment_for_client_id(assignment_uuid, user_obj, client_id)
if assign is not None:
assign.delete_instance()
url = utils.build_url(redirect_uri, params)
url += "#access_token=%s&token_type=%s&expires_in=%s" % (
access_token,
@ -274,6 +288,13 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
def discard_refresh_token(self, client_id, refresh_token):
raise NotImplementedError()
def is_org_admin_or_has_token_assignment(self, organization, assignment_uuid):
return (
is_org_admin(self.get_authorized_user(), organization)
or get_token_assignment(assignment_uuid, self.get_authorized_user(), organization)
is not None
)
def create_application(org, name, application_uri, redirect_uri, **kwargs):
client_secret = kwargs.pop("client_secret", random_string_generator(length=40)())
@ -337,12 +358,15 @@ def lookup_application(org, client_id):
def delete_application(org, client_id):
application = lookup_application(org, client_id)
if not application:
return
with db_transaction():
application = lookup_application(org, client_id)
if not application:
return
application.delete_instance(recursive=True, delete_nullable=True)
return application
OauthAssignedToken.delete().where(OauthAssignedToken.application == application).execute()
application.delete_instance(recursive=True, delete_nullable=True)
return application
def lookup_access_token_by_uuid(token_uuid):
@ -378,12 +402,42 @@ def list_access_tokens_for_user(user_obj):
return query
def get_assigned_authorization_for_user(user_obj, uuid):
try:
assigned_token = (
OauthAssignedToken.select()
.join(OAuthApplication)
.where(OauthAssignedToken.assigned_user == user_obj, OauthAssignedToken.uuid == uuid)
.get()
)
return assigned_token
except OauthAssignedToken.DoesNotExist:
return None
def list_assigned_authorizations_for_user(user_obj):
query = (
OauthAssignedToken.select()
.join(OAuthApplication)
.where(OauthAssignedToken.assigned_user == user_obj)
)
return query
def list_applications_for_org(org):
query = OAuthApplication.select().join(User).where(OAuthApplication.organization == org)
return query
def get_oauth_application_for_client_id(client_id):
try:
return OAuthApplication.get(client_id=client_id)
except OAuthApplication.DoesNotExist:
return None
def create_user_access_token(user_obj, client_id, scope, access_token=None, expires_in=9000):
access_token = access_token or random_string_generator(length=40)()
token_name = access_token[:ACCESS_TOKEN_PREFIX_LENGTH]
@ -406,3 +460,59 @@ def create_user_access_token(user_obj, client_id, scope, access_token=None, expi
data="",
)
return created, access_token
def assign_token_to_user(application, user, redirect_uri, scope, response_type):
with db_transaction():
token = OauthAssignedToken.create(
application=application,
assigned_user=user,
redirect_uri=redirect_uri,
scope=scope,
response_type=response_type,
)
notification.create_notification(
"assigned_authorization",
user,
{
"username": user.username,
},
)
return token
def get_token_assignment(uuid, db_user, org):
if uuid is None:
return None
try:
return (
OauthAssignedToken.select()
.join(OAuthApplication)
.where(
OauthAssignedToken.uuid == uuid,
OauthAssignedToken.assigned_user == db_user,
OAuthApplication.organization == org,
)
.get()
)
except OauthAssignedToken.DoesNotExist:
return None
def get_token_assignment_for_client_id(uuid, user, client_id):
try:
return (
OauthAssignedToken.select()
.join(OAuthApplication)
.where(
OauthAssignedToken.uuid == uuid,
OauthAssignedToken.assigned_user == user,
OAuthApplication.client_id == client_id,
)
.get()
)
except OauthAssignedToken.DoesNotExist:
return None

View File

@ -194,3 +194,14 @@ def add_user_as_admin(user_obj, org_obj):
team.add_user_to_team(user_obj, admin_team)
except team.UserAlreadyInTeam:
pass
def is_org_admin(user, org):
return (
Team.select()
.join(TeamMember)
.switch(Team)
.join(TeamRole)
.where(Team.organization == org, TeamRole.name == "admin", TeamMember.user == user)
.exists()
)

View File

@ -0,0 +1,96 @@
from unittest.mock import patch
from auth.scopes import READ_REPO
from data import model
from data.model.oauth import DatabaseAuthorizationProvider
from test.fixtures import *
REDIRECT_URI = "http://foo/bar/baz"
class MockDatabaseAuthorizationProvider(DatabaseAuthorizationProvider):
def get_authorized_user(self):
return model.user.get_user("devtable")
def setup(num_assignments=0):
user = model.user.get_user("devtable")
org = model.organization.get_organization("buynlarge")
application = model.oauth.create_application(org, "test", "http://foo/bar", REDIRECT_URI)
token_assignment = model.oauth.assign_token_to_user(
application, user, REDIRECT_URI, READ_REPO.scope, "token"
)
return (application, token_assignment, user, org)
def test_get_token_response_with_assignment_id(initialized_db):
application, token_assignment, user, org = setup()
with patch("data.model.oauth.url_for", return_value="http://foo/bar/baz"):
db_auth_provider = MockDatabaseAuthorizationProvider()
response = db_auth_provider.get_token_response(
"token",
application.client_id,
REDIRECT_URI,
token_assignment.uuid,
scope=READ_REPO.scope,
)
assert response.status_code == 302
assert "error" not in response.headers["Location"]
assert model.oauth.get_token_assignment(token_assignment.uuid, user, org) is None
def test_delete_application(initialized_db):
application, token_assignment, user, org = setup()
model.oauth.delete_application(org, application.client_id)
assert model.oauth.get_token_assignment(token_assignment.uuid, user, org) is None
assert model.oauth.lookup_application(org, application.client_id) is None
def test_get_assigned_authorization_for_user(initialized_db):
application, token_assignment, user, org = setup()
assigned_oauth = model.oauth.get_assigned_authorization_for_user(user, token_assignment.uuid)
assert assigned_oauth is not None
assert assigned_oauth.uuid == token_assignment.uuid
def test_list_assigned_authorizations_for_user(initialized_db):
application, token_assignment, user, org = setup()
second_token_assignment = model.oauth.assign_token_to_user(
application, user, REDIRECT_URI, READ_REPO.scope, "token"
)
assigned_oauths = model.oauth.list_assigned_authorizations_for_user(user)
assert len(assigned_oauths) == 3
assert assigned_oauths[1].uuid == token_assignment.uuid
assert assigned_oauths[2].uuid == second_token_assignment.uuid
def test_get_oauth_application_for_client_id(initialized_db):
application, token_assignment, user, org = setup()
assert model.oauth.get_oauth_application_for_client_id(application.client_id) == application
def test_assign_token_to_user(initialized_db):
application, token_assignment, user, org = setup()
created_assignment = model.oauth.get_token_assignment(token_assignment.uuid, user, org)
assert created_assignment is not None
assert created_assignment.uuid == token_assignment.uuid
assert created_assignment.application == application
assert created_assignment.assigned_user == user
assert created_assignment.redirect_uri == REDIRECT_URI
assert created_assignment.scope == READ_REPO.scope
assert created_assignment.response_type == "token"
def test_get_token_assignment_for_client_id(initialized_db):
application, token_assignment, user, org = setup()
assert (
model.oauth.get_token_assignment_for_client_id(
token_assignment.uuid, user, application.client_id
)
== token_assignment
)

View File

@ -1,10 +1,9 @@
from test.fixtures import *
import pytest
from data.model.organization import get_organization, get_organizations
from data.model.user import mark_namespace_for_deletion
from data.model.organization import get_organization, get_organizations, is_org_admin
from data.model.user import get_user, mark_namespace_for_deletion
from data.queue import WorkQueue
from test.fixtures import *
@pytest.mark.parametrize(
@ -25,3 +24,9 @@ def test_get_organizations(deleted, initialized_db):
deleted_found = [org for org in orgs if org.id == deleted_org.id]
assert bool(deleted_found) == deleted
def test_is_org_admin(initialized_db):
user = get_user("devtable")
org = get_organization("sellnsmall")
assert is_org_admin(user, org) is True

View File

@ -1,15 +1,19 @@
from datetime import datetime
from test.fixtures import *
from test.helpers import check_transitive_modifications
import pytest
from mock import patch
from auth.scopes import READ_REPO
from data import model
from data.database import DeletedNamespace, EmailConfirmation, FederatedLogin, User
from data.fields import Credential
from data.model.notification import create_notification
from data.model.organization import get_organization
from data.model.oauth import (
assign_token_to_user,
get_oauth_application_for_client_id,
get_token_assignment,
)
from data.model.organization import create_organization, get_organization
from data.model.repository import create_repository
from data.model.team import add_user_to_team, create_team
from data.model.user import (
@ -26,6 +30,7 @@ from data.model.user import (
get_public_repo_count,
get_pull_credentials,
get_quay_user_from_federated_login_name,
get_user,
list_namespace_robots,
lookup_robot,
mark_namespace_for_deletion,
@ -34,6 +39,8 @@ from data.model.user import (
verify_robot,
)
from data.queue import WorkQueue
from test.fixtures import *
from test.helpers import check_transitive_modifications
from util.security.token import encode_public_private_token
from util.timedeltastring import convert_to_timedelta
@ -96,7 +103,7 @@ def test_get_active_users(disabled, deleted, initialized_db):
assert user.enabled
def test_mark_namespace_for_deletion(initialized_db):
def test_mark_user_for_deletion(initialized_db):
def create_transaction(db):
return db.transaction()
@ -117,6 +124,16 @@ def test_mark_namespace_for_deletion(initialized_db):
assert FederatedLogin.select().where(FederatedLogin.user == user).count() == 2
assert FederatedLogin.select().where(FederatedLogin.service_ident == "someusername").exists()
# Add an oauth assigned token
org = model.organization.get_organization("buynlarge")
application = model.oauth.create_application(
org, "test", "http://foo/bar", "http://foo/bar/baz"
)
assigned_token = assign_token_to_user(
application, user, "http://foo/bar/baz", READ_REPO.scope, "token"
)
assert get_token_assignment(assigned_token.uuid, user, org) is not None
# Mark the user for deletion.
queue = WorkQueue("testgcnamespace", create_transaction)
mark_namespace_for_deletion(user, [], queue)
@ -140,6 +157,9 @@ def test_mark_namespace_for_deletion(initialized_db):
not FederatedLogin.select().where(FederatedLogin.service_ident == "someusername").exists()
)
# Ensure the oauth assigned token is gone
assert get_token_assignment(assigned_token.uuid, user, org) is None
# Ensure we can create a user with the same namespace again.
new_user = create_user_noverify("foobar", "foo@example.com", email_required=False)
assert new_user.id != user.id
@ -148,6 +168,76 @@ def test_mark_namespace_for_deletion(initialized_db):
assert User.get(id=user.id).username != "foobar"
def test_mark_organization_for_deletion(initialized_db):
def create_transaction(db):
return db.transaction()
# Create a user and then mark it for deletion.
user = get_user("devtable")
org = create_organization("foobar", "foobar@devtable.com", user)
# Add some robots.
create_robot("foo", org)
create_robot("bar", org)
assert lookup_robot("foobar+foo") is not None
assert lookup_robot("foobar+bar") is not None
assert len(list(list_namespace_robots("foobar"))) == 2
# Add some federated user links.
attach_federated_login(org, "google", "someusername")
attach_federated_login(org, "github", "someusername")
assert FederatedLogin.select().where(FederatedLogin.user == org).count() == 2
assert FederatedLogin.select().where(FederatedLogin.service_ident == "someusername").exists()
# Add an oauth assigned token and application
application1 = model.oauth.create_application(
org, "test", "http://foo/bar", "http://foo/bar/baz"
)
application = model.oauth.create_application(
org, "test2", "http://foo/bar", "http://foo/bar/baz"
)
assigned_token = assign_token_to_user(
application, user, "http://foo/bar/baz", READ_REPO.scope, "token"
)
assert get_token_assignment(assigned_token.uuid, user, org) is not None
# Mark the user for deletion.
queue = WorkQueue("testgcnamespace", create_transaction)
mark_namespace_for_deletion(org, [], queue)
# Ensure the older user is still in the DB.
older_user = User.get(id=org.id)
assert older_user.username != "foobar"
# Ensure the robots are deleted.
with pytest.raises(InvalidRobotException):
assert lookup_robot("foobar+foo")
with pytest.raises(InvalidRobotException):
assert lookup_robot("foobar+bar")
assert len(list(list_namespace_robots(older_user.username))) == 0
# Ensure the federated logins are gone.
assert FederatedLogin.select().where(FederatedLogin.user == org).count() == 0
assert (
not FederatedLogin.select().where(FederatedLogin.service_ident == "someusername").exists()
)
# Ensure the oauth assigned token is gone
assert get_oauth_application_for_client_id(application1.client_id) is None
assert get_oauth_application_for_client_id(application.client_id) is None
assert get_token_assignment(assigned_token.uuid, org, org) is None
# Ensure we can create a user with the same namespace again.
new_org = create_organization("foobar", "foobar@devtable.com", user)
assert new_org.id != org.id
# Ensure the older org is still in the DB.
assert User.get(id=org.id).username != "foobar"
def test_delete_namespace_via_marker(initialized_db):
def create_transaction(db):
return db.transaction()

View File

@ -19,6 +19,7 @@ from data.database import (
NamespaceAutoPrunePolicy,
NamespaceGeoRestriction,
OAuthApplication,
OauthAssignedToken,
QuotaNamespaceSize,
RepoMirrorConfig,
Repository,
@ -1355,6 +1356,7 @@ def _delete_user_linked_data(user):
# Delete any OAuth approvals and tokens associated with the user.
with db_transaction():
for app in OAuthApplication.select().where(OAuthApplication.organization == user):
OauthAssignedToken.delete().where(OauthAssignedToken.application == app).execute()
app.delete_instance(recursive=True)
else:
# Remove the user from any teams in which they are a member.
@ -1394,6 +1396,9 @@ def _delete_user_linked_data(user):
# Delete the quota size entry
QuotaNamespaceSize.delete().where(QuotaNamespaceSize.namespace_user == user).execute()
# Delete any oauth assigned tokens
OauthAssignedToken.delete().where(OauthAssignedToken.assigned_user == user).execute()
def get_pull_credentials(robotname):
"""

View File

@ -5075,6 +5075,35 @@ SECURITY_TESTS: List[
(UserAuthorization, "GET", {"access_token_uuid": "fake"}, None, "devtable", 404),
(UserAuthorization, "GET", {"access_token_uuid": "fake"}, None, "freshuser", 404),
(UserAuthorization, "GET", {"access_token_uuid": "fake"}, None, "reader", 404),
(UserAssignedAuthorizations, "GET", None, None, None, 401),
(UserAssignedAuthorizations, "GET", None, None, "devtable", 200),
(UserAssignedAuthorizations, "GET", None, None, "freshuser", 200),
(UserAssignedAuthorizations, "GET", None, None, "reader", 200),
(UserAssignedAuthorization, "DELETE", {"assigned_authorization_uuid": "fake"}, None, None, 401),
(
UserAssignedAuthorization,
"DELETE",
{"assigned_authorization_uuid": "fake"},
None,
"devtable",
404,
),
(
UserAssignedAuthorization,
"DELETE",
{"assigned_authorization_uuid": "fake"},
None,
"freshuser",
404,
),
(
UserAssignedAuthorization,
"DELETE",
{"assigned_authorization_uuid": "fake"},
None,
"reader",
404,
),
(UserAggregateLogs, "GET", None, None, None, 401),
(UserAggregateLogs, "GET", None, None, "devtable", 200),
(UserAggregateLogs, "GET", None, None, "freshuser", 200),

View File

@ -34,6 +34,8 @@ from auth.permissions import (
from data import model
from data.billing import get_plan
from data.database import Repository as RepositoryTable
from data.model.notification import delete_notifications_by_kind
from data.model.oauth import get_assigned_authorization_for_user
from data.users.shared import can_create_user
from endpoints.api import (
ApiResource,
@ -1188,6 +1190,28 @@ def authorization_view(access_token):
}
def assigned_authorization_view(assigned_authorization):
oauth_app = assigned_authorization.application
app_email = oauth_app.avatar_email or oauth_app.organization.email
return {
"application": {
"name": oauth_app.name,
"clientId": oauth_app.client_id,
"description": oauth_app.description,
"url": oauth_app.application_uri,
"avatar": avatar.get_data(oauth_app.name, app_email, "app"),
"organization": {
"name": oauth_app.organization.username,
"avatar": avatar.get_data_for_org(oauth_app.organization),
},
},
"uuid": assigned_authorization.uuid,
"redirectUri": assigned_authorization.redirect_uri,
"scopes": scopes.get_scope_information(assigned_authorization.scope),
"responseType": assigned_authorization.response_type,
}
@resource("/v1/user/authorizations")
@internal_only
class UserAuthorizationList(ApiResource):
@ -1227,6 +1251,45 @@ class UserAuthorization(ApiResource):
return "", 204
@resource("/v1/user/assignedauthorization")
@show_if(features.ASSIGN_OAUTH_TOKEN)
@internal_only
class UserAssignedAuthorizations(ApiResource):
@require_user_admin()
@nickname("listAssignedAuthorizations")
def get(self):
user = get_authenticated_user()
assignments = model.oauth.list_assigned_authorizations_for_user(user)
# Delete any notifications for assigned authorizations, since they have now been viewed
delete_notifications_by_kind(user, "assigned_authorization")
return {
"authorizations": [
assigned_authorization_view(assignment) for assignment in assignments
]
}
@resource("/v1/user/assignedauthorization/<assigned_authorization_uuid>")
@show_if(features.ASSIGN_OAUTH_TOKEN)
@internal_only
class UserAssignedAuthorization(ApiResource):
@require_user_admin()
@nickname("deleteAssignedAuthorization")
def delete(self, assigned_authorization_uuid):
assigned_authorization = get_assigned_authorization_for_user(
get_authenticated_user(), assigned_authorization_uuid
)
if not assigned_authorization:
raise NotFound()
assigned_authorization.delete_instance()
return "", 204
@resource("/v1/user/starred")
class StarredRepositoryList(ApiResource):
"""

View File

@ -50,6 +50,13 @@ from buildtrigger.triggerutil import TriggerProviderException
from config import frontend_visible_config
from data import model
from data.database import User, db, random_string_generator
from data.model.oauth import (
assign_token_to_user,
get_oauth_application_for_client_id,
get_token_assignment,
)
from data.model.organization import is_org_admin
from data.model.user import get_nonrobot_user, get_user
from endpoints.api import log_action
from endpoints.api.discovery import swagger_route_data
from endpoints.common import (
@ -644,6 +651,14 @@ def authorize_application():
response_type = request.form.get("response_type", "code")
scope = request.form.get("scope", None)
state = request.form.get("state", None)
assignment_uuid = request.form.get("assignment_uuid", None)
# assignment currently only supported for token response type
if response_type != "token" and assignment_uuid is not None:
abort(400)
if not features.ASSIGN_OAUTH_TOKEN and assignment_uuid is not None:
abort(400)
# Add the access token.
if response_type == "token":
@ -651,6 +666,7 @@ def authorize_application():
response_type,
client_id,
redirect_uri,
assignment_uuid,
scope=scope,
state=state,
)
@ -705,10 +721,32 @@ def request_authorization_code():
redirect_uri = request.args.get("redirect_uri", None)
scope = request.args.get("scope", None)
state = request.args.get("state", None)
assignment_uuid = request.args.get("assignment_uuid", None)
if not current_user.is_authenticated or not provider.validate_has_scopes(
client_id, current_user.db_user().username, scope
if not get_authenticated_user():
abort(401)
return
if not features.ASSIGN_OAUTH_TOKEN and assignment_uuid is not None:
abort(400)
# assignment currently only supported for token response type
if response_type != "token" and assignment_uuid is not None:
abort(400)
oauth_app = provider.get_application_for_client_id(client_id)
if not oauth_app:
abort(404)
# check if user is org admin, if not check for user_assignment_id, then check that user belongs that assignment, if none exit with 401
if (
not is_org_admin(current_user.db_user(), oauth_app.organization)
and get_token_assignment(assignment_uuid, current_user.db_user(), oauth_app.organization)
is None
):
abort(403)
if not provider.validate_has_scopes(client_id, current_user.db_user().username, scope):
if not provider.validate_redirect_uri(client_id, redirect_uri):
current_app = provider.get_application_for_client_id(client_id)
if not current_app:
@ -724,10 +762,7 @@ def request_authorization_code():
abort(404)
return
# Load the application information.
oauth_app = provider.get_application_for_client_id(client_id)
app_email = oauth_app.avatar_email or oauth_app.organization.email
oauth_app_view = {
"name": oauth_app.name,
"description": oauth_app.description,
@ -753,6 +788,7 @@ def request_authorization_code():
scope=scope,
csrf_token_val=generate_csrf_token(),
state=state,
assignment_uuid=assignment_uuid,
)
if response_type == "token":
@ -760,6 +796,7 @@ def request_authorization_code():
response_type,
client_id,
redirect_uri,
assignment_uuid,
scope=scope,
state=state,
)
@ -773,6 +810,49 @@ def request_authorization_code():
)
@web.route("/oauth/authorize/assignuser", methods=["POST"])
@no_cache
@param_required("client_id")
@param_required("redirect_uri")
@param_required("scope")
@param_required("username")
@process_auth_or_cookie
def assign_user_to_app():
response_type = request.args.get("response_type", "code")
client_id = request.args.get("client_id", None)
redirect_uri = request.args.get("redirect_uri", None)
scope = request.args.get("scope", None)
username = request.args.get("username", None)
if not features.ASSIGN_OAUTH_TOKEN:
abort(404)
if not current_user.is_authenticated:
abort(401)
user = get_nonrobot_user(username)
if not user or not user.enabled:
abort(404)
application = get_oauth_application_for_client_id(client_id)
if not application:
abort(404)
if not is_org_admin(current_user.db_user(), application.organization):
abort(403)
assign_token_to_user(
application,
user,
redirect_uri,
scope,
response_type,
)
return render_page_template_with_routedata(
"message.html", message="Token assigned successfully"
)
@web.route("/oauth/access_token", methods=["POST"])
@no_cache
@param_required("grant_type", allow_body=True)

View File

@ -217,3 +217,4 @@ MANIFEST_SUBJECT_BACKFILL: FeatureNameValue
REFERRERS_API: FeatureNameValue
SUPERUSERS_FULL_ACCESS: FeatureNameValue
ASSIGN_OAUTH_TOKEN: FeatureNameValue

View File

@ -74,6 +74,7 @@ from data.model.autoprune import (
create_namespace_autoprune_policy,
create_repository_autoprune_policy,
)
from data.model.oauth import assign_token_to_user
from data.queue import WorkQueue
from data.registry_model import registry_model
from data.registry_model.datatypes import RepositoryReference
@ -531,6 +532,8 @@ def initialize_database():
NotificationKind.create(name="quota_warning")
NotificationKind.create(name="quota_error")
NotificationKind.create(name="assigned_authorization")
QuayRegion.create(name="us")
QuayService.create(name="quay")
@ -984,6 +987,9 @@ def populate_database(minimal=False):
"http://localhost:8000/o2c.html",
client_id="deadbeef",
)
assign_token_to_user(
oauth_app_1, new_user_1, "http://localhost:8000/o2c.html", "repo:admin", "token"
)
model.oauth.create_application(
org,

View File

@ -8,14 +8,15 @@
<div class="resource-view" resource="authorizedAppsResource"
error-message="'Cannot load authorized applications'"></div>
<div class="empty" ng-if="!authorizedApps.length">
<div class="empty" ng-if="!authorizedApps.length && !assignedAuthApps.length">
<div class="empty-primary-msg">You have not authorized any external applications.</div>
</div>
<table class="cor-table" ng-if="authorizedApps.length">
<table class="cor-table" ng-if="authorizedApps.length || assignedAuthApps.length">
<thead>
<td>Application Name</td>
<td>Authorized Permissions</td>
<td ng-if="assignedAuthApps.length > 0">Confirm</td>
<td class="options-col"></td>
</thead>
@ -40,6 +41,9 @@
bs-tooltip>
{{ scopeInfo.scope }}
</span>
</td>
<td>
</td>
<td class="options-col">
<span class="cor-options-menu">
@ -49,6 +53,41 @@
</span>
</td>
</tr>
<tr class="auth-info" ng-repeat="authInfo in assignedAuthApps">
<td>
<span class="avatar" size="24" data="authInfo.application.avatar"></span>
<a href="{{ authInfo.application.url }}" ng-if="authInfo.application.url" ng-safenewtab
data-title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
{{ authInfo.application.name }}
</a>
<span ng-if="!authInfo.application.url"
data-title="{{ authInfo.application.description || authInfo.application.name }}"
bs-tooltip>
{{ authInfo.application.name }}
</span>
<span class="by">{{ authInfo.application.organization.name }}</span>
</td>
<td>
<span class="label label-default scope"
ng-class="{'repo:admin': 'label-primary', 'repo:write': 'label-success', 'repo:create': 'label-success'}[scopeInfo.scope]"
ng-repeat="scopeInfo in authInfo.scopes" data-title="{{ scopeInfo.description }}"
bs-tooltip>
{{ scopeInfo.scope }}
</span>
</td>
<td>
<a target="_blank" rel="noopener noreferrer" href="{{ getAuthorizationUrl(authInfo) }}">
Authorize Application
</a>
</td>
<td class="options-col">
<span class="cor-options-menu">
<span class="cor-option" option-click="deleteAssignedAuthorization(authInfo)">
<i class="fa fa-times"></i> Delete Authorization
</span>
</span>
</td>
</tr>
</table>
</div>
</div>

View File

@ -12,10 +12,11 @@ angular.module('quay').directive('authorizedAppsManager', function () {
'user': '=user',
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService) {
controller: function($scope, $element, ApiService, Config) {
$scope.$watch('isEnabled', function(enabled) {
if (!enabled) { return; }
loadAuthedApps();
loadAssignedAuthApps();
});
var loadAuthedApps = function() {
@ -26,6 +27,17 @@ angular.module('quay').directive('authorizedAppsManager', function () {
});
};
var loadAssignedAuthApps = function(){
if ($scope.assignedAuthAppsResource || !Config.FEATURE_ASSIGN_OAUTH_TOKEN) {
$scope.assignedAuthApps = [];
return;
}
$scope.assignedAuthAppsResource = ApiService.listAssignedAuthorizationsAsResource().get(function(resp) {
$scope.assignedAuthApps = resp['authorizations'];
});
}
$scope.deleteAccess = function(accessTokenInfo) {
var params = {
'access_token_uuid': accessTokenInfo['uuid']
@ -35,7 +47,31 @@ angular.module('quay').directive('authorizedAppsManager', function () {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, ApiService.errorDisplay('Could not revoke authorization'));
};
$scope.deleteAssignedAuthorization = function(assignedAuthorization){
if(!Config.FEATURE_ASSIGN_OAUTH_TOKEN){
return;
}
var params = {
'assigned_authorization_uuid': assignedAuthorization['uuid']
};
ApiService.deleteAssignedAuthorization(null, params).then(function(resp) {
$scope.assignedAuthApps.splice($scope.assignedAuthApps.indexOf(assignedAuthorization), 1);
}, ApiService.errorDisplay('Could not revoke assigned authorization'));
}
$scope.getAuthorizationUrl = function(assignedAuthorization){
let scopes = assignedAuthorization.scopes.map((scope) => scope.scope).join(' ');
let url = "/oauth/authorize?";
url += "response_type=" + assignedAuthorization.responseType;
url += "&client_id=" + assignedAuthorization.application.clientId;
url += "&scope=" + scopes;
url += "&redirect_uri=" + assignedAuthorization.redirectUri;
url += "&assignment_uuid=" + assignedAuthorization.uuid;
return Config.getUrl(url);
}
}
};
return directiveDefinitionObject;
});
});

View File

@ -17,9 +17,12 @@
$scope.Config = Config;
$scope.OAuthService = OAuthService;
$scope.updating = false;
$scope.currentEntity = null;
$scope.selectedUser = null;
$scope.customUser = false;
$scope.genScopes = {};
UserService.updateUserIn($scope);
$scope.getScopes = function(scopes) {
@ -92,6 +95,48 @@
}, ApiService.errorDisplay('Could not reset client secret'));
};
$scope.generateUrl = function() {
if($scope.application == null){
return "";
}
var base = $scope.selectedUser !== null ? '/oauth/authorize/assignuser?username=' + $scope.selectedUser.name + '&' : '/oauth/authorize?' ;
var url = base + 'response_type=token&client_id=' + $scope.application.client_id + '&scope=' + $scope.getScopes($scope.genScopes).join(' ') + '&redirect_uri=' + Config.getUrl(Config['LOCAL_OAUTH_HANDLER']);
return Config.getUrl(url);
}
$scope.setSelectedUser = function(entity){
$scope.selectedUser = entity;
}
$scope.getScopeInfo = function() {
var selectedScopes = $scope.getScopes($scope.genScopes);
var scopeDetails = [];
for (var i = 0; i < selectedScopes.length; ++i) {
var scope = selectedScopes[i];
if(OAuthService.SCOPES[scope] !== undefined){
var scopeInfo = OAuthService.SCOPES[scope];
scopeInfo.index = i; // Add index for rendering list
scopeDetails.push(scopeInfo)
}
}
return scopeDetails;
}
$scope.hasDangerousScope = function() {
return $scope.getScopeInfo().some(function(scope){
return scope.dangerous;
})
}
$scope.confirmAssignUser = function(){
$('#confirmAssignAuthorizationModal').modal({});
}
$scope.closeConfirmAssignUser = function(){
$('#confirmAssignAuthorizationModal').modal('hide');
}
var loadOrganization = function() {
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
$scope.organization = org;
@ -121,4 +166,4 @@
loadOrganization();
loadApplicationInfo();
}
})();
})();

View File

@ -244,7 +244,15 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
'page': function(metadata) {
return '/superuser/?tab=servicekeys';
},
}
},
'assigned_authorization': {
'level': 'primary',
'message': 'You have been assigned an Oauth authorization. Please approve or deny the request.',
'page': function(metadata) {
return '/user/'+metadata["username"]+'/?tab=external';
},
'dismissable': true
},
};
notificationService.dismissNotification = function(notification) {

View File

@ -100,11 +100,29 @@
<div style="margin-bottom: 10px">
Click the button below to generate a new <a href="http://tools.ietf.org/html/rfc6749#section-1.4" target="_new">OAuth 2 Access Token</a>.
</div>
<div>
The generated token will act on behalf of user
<span class="avatar" data="user.avatar" size="16" style="margin-left: 6px; margin-right: 4px;"></span>
<span class="user-name">{{ user.username }}</span>
<span class="avatar" data="user.avatar" size="16" style="margin-left: 6px; margin-right: 4px;" ng-if="!customUser"></span>
<span class="user-name" ng-if="!customUser">{{ user.username }}</span>
<span class="user-name" ng-if="customUser">
<div class="entity-search"
namespace="organization.name"
placeholder="'User'"
entity-selected="setSelectedUser(entity)"
current-entity="selectedUser"
allowed-entities="['user']"
pull-right="true"></div>
</span>
<span ng-show="Config.FEATURE_ASSIGN_OAUTH_TOKEN">
<button class="btn btn-primary" ng-click="customUser = !customUser">
<div ng-if="!customUser">
Assign another user
</div>
<div ng-if="customUser">
Cancel
</div>
</button>
</span>
</div>
</div>
@ -120,10 +138,13 @@
</table>
<a class="btn btn-success"
href="{{ Config.getUrl('/oauth/authorize?response_type=token&client_id=' + application.client_id + '&scope=' + getScopes(genScopes).join(' ') + '&redirect_uri=' + Config.getUrl(Config['LOCAL_OAUTH_HANDLER'])) }}"
ng-disabled="!getScopes(genScopes).length" ng-safenewtab>
href="{{ generateUrl() }}"
ng-disabled="!getScopes(genScopes).length" ng-safenewtab ng-if="!customUser">
Generate Access Token
</a>
<button class="btn btn-success" ng-click="confirmAssignUser()" ng-disabled="selectedUser == null" ng-if="customUser">
Assign token
</button>
</cor-tab-pane>
<!-- OAuth tab -->
@ -184,3 +205,56 @@
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmAssignAuthorizationModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Assign Authorization?</h4>
</div>
<div class="modal-body">
<div class="auth-scopes">
<div ng-if="hasDangerousScope()" class="alert alert-danger">Dangerous scopes will be granted to <b><a class="entity-name" ng-safenewtab href="/user/{{selectedUser.name}}"><i class="fa fa-user"></i>{{selectedUser.name}}</a></b>. Please ensure the scopes and the user are correct.</div>
<div class="reason">This will prompt user <b><a class="entity-name" ng-safenewtab href="/user/{{selectedUser.name}}"><i class="fa fa-user"></i>{{selectedUser.name}}</a></b> to generate a token with the following permissions:</div>
<div class="panel-group">
<div class="scope panel panel-default {{ scope.dangerous && 'dangerous'}}" ng-repeat="scope in getScopeInfo() track by $index">
<div class="panel-heading">
<h4 class="panel-title">
<div class="title-container">
<div class="title {{ !scope.dangerous && 'dangerous'}}" data-toggle="collapse"
data-parent="#scopeGroup" data-target="#description-{{ scope.index }}">
<i class="fa arrow"></i>
<i class="fa {{ scope.icon }} fa-lg"></i>
<a data-toggle="collapse" href="#collapseOne">
{{ scope.scope }}
</a>
<i ng-if="scope.dangerous" class="fa fa-lg fa-exclamation-triangle"
data-title="This scope grants permissions which are potentially dangerous. Be careful when authorizing it!"
data-container="body" bs-tooltip></i>
</div>
</div>
</h4>
</div>
<div id="description-{{ scope.index }}" class="panel-collapse {{ !scope.dangerous ? 'collapse' : 'in'}}">
<div class="panel-body">
<div ng-if="scope.dangerous" class="alert alert-warning">This scope grants permissions which are potentially dangerous. Be careful when authorizing it!</div>
{{ scope.description }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<form ng-if="selectedUser != null" method="post" action="{{ generateUrl() }}" ng-safenewtab>
<button type="submit" class="btn btn-success">
Assign token
</button>
<button type="button" class="btn btn-default" ng-click="closeConfirmAssignUser()">Close</button>
</form>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View File

@ -72,6 +72,9 @@
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token_val }}">
<input type="hidden" name="state" value="{{ state }}">
{% if assignment_uuid %}
<input type="hidden" name="assignment_uuid" value="{{ assignment_uuid }}">
{% endif %}
<button type="submit" class="btn btn-success">Authorize Application</button>
</form>
{% endif %}
@ -106,6 +109,7 @@
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token_val }}">
<input type="hidden" name="assignment_uuid" value="{{ assignment_uuid }}">
<button type="submit" class="btn btn-success">Authorize Application</button>
</form>
<form method="post" action="/oauth/denyapp" style="display: inline-block">

View File

@ -25,11 +25,14 @@ from app import (
notification_queue,
storage,
)
from auth.scopes import READ_REPO, get_scope_information
from buildtrigger.basehandler import BuildTriggerHandler
from data import database, model
from data.database import Repository as RepositoryTable
from data.database import RepositoryActionCount
from data.logs_model import logs_model
from data.model.organization import create_organization
from data.model.user import get_user
from data.registry_model import registry_model
from endpoints.api import api, api_bp
from endpoints.api.billing import (
@ -138,6 +141,8 @@ from endpoints.api.user import (
StarredRepository,
StarredRepositoryList,
User,
UserAssignedAuthorization,
UserAssignedAuthorizations,
UserAuthorization,
UserAuthorizationList,
UserNotification,
@ -4599,6 +4604,81 @@ class TestUserAuthorizations(ApiTestCase):
)
class TestUserAssignedAuthorizations(ApiTestCase):
def test_list_authorizations(self):
assigned_scope = READ_REPO.scope
self.login(PUBLIC_USER)
admin = get_user(ADMIN_ACCESS_USER)
assigned_user = get_user(PUBLIC_USER)
org = create_organization("neworg", "neworg@devtable.com", admin)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assigned_authorization = model.oauth.assign_token_to_user(
app, assigned_user, app.redirect_uri, assigned_scope, "token"
)
response = self.getJsonResponse(
UserAssignedAuthorizations,
expected_code=200,
)
assert len(response["authorizations"]) == 1
authorization = response["authorizations"][0]
del authorization["application"]["avatar"]
del authorization["application"]["organization"]["avatar"]
assert authorization == {
"application": {
"name": app.name,
"clientId": app.client_id,
"description": app.description,
"url": app.application_uri,
"organization": {
"name": org.username,
},
},
"uuid": assigned_authorization.uuid,
"redirectUri": assigned_authorization.redirect_uri,
"scopes": get_scope_information(assigned_scope),
"responseType": assigned_authorization.response_type,
}
class TestUserAssignedAuthorization(ApiTestCase):
def test_delete_assigned_authorization(self):
assigned_scope = READ_REPO.scope
self.login(PUBLIC_USER)
admin = get_user(ADMIN_ACCESS_USER)
assigned_user = get_user(PUBLIC_USER)
org = create_organization("neworg", "neworg@devtable.com", admin)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assigned_authorization = model.oauth.assign_token_to_user(
app, assigned_user, app.redirect_uri, assigned_scope, "token"
)
response = self.getJsonResponse(
UserAssignedAuthorizations,
expected_code=200,
)
assert len(response["authorizations"]) == 1
self.deleteEmptyResponse(
UserAssignedAuthorization,
params=dict(assigned_authorization_uuid=assigned_authorization.uuid),
)
response = self.getJsonResponse(
UserAssignedAuthorizations,
expected_code=200,
)
assert len(response["authorizations"]) == 0
def test_delete_assigned_authorization_not_found(self):
self.login(PUBLIC_USER)
self.deleteResponse(
UserAssignedAuthorization,
params=dict(assigned_authorization_uuid="doesnotexist"),
expected_code=404,
)
class TestSuperUserLogs(ApiTestCase):
def test_get_logs(self):
self.login(ADMIN_ACCESS_USER)

View File

@ -7,7 +7,6 @@ import unittest
import zlib
from datetime import datetime, timedelta
from io import BytesIO
from test.helpers import assert_action_logged
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import jwt
@ -19,16 +18,21 @@ from parameterized import parameterized, parameterized_class
from app import app
from data import model
from data.database import ServiceKeyApprovalType
from data.database import NotificationKind, ServiceKeyApprovalType
from data.model.notification import list_notifications
from data.model.oauth import create_user_access_token
from data.model.organization import create_organization, get_organization
from data.model.user import get_user
from endpoints import keyserver
from endpoints.api import api, api_bp
from endpoints.api.user import Signin
from endpoints.csrf import OAUTH_CSRF_TOKEN_NAME
from endpoints.keyserver import jwk_with_kid
from endpoints.test.shared import gen_basic_auth
from endpoints.test.shared import gen_basic_auth, toggle_feature
from endpoints.web import web as web_bp
from endpoints.webhooks import webhooks as webhooks_bp
from initdb import finished_database_for_testing, setup_database_for_testing
from test.helpers import assert_action_logged
from util.registry.gzipinputstream import WINDOW_BUFFER_SIZE
from util.security.token import encode_public_private_token
@ -65,11 +69,15 @@ class EndpointTestCase(unittest.TestCase):
def _add_csrf(self, without_csrf):
parts = urlparse(without_csrf)
query = parse_qs(parts[4])
for k, v in query.items():
query[k] = v[0]
self._set_csrf()
query[CSRF_TOKEN_KEY] = CSRF_TOKEN
return urlunparse(list(parts[0:4]) + [urlencode(query)] + list(parts[5:]))
url = urlunparse(list(parts[0:4]) + [urlencode(query)] + list(parts[5:]))
return url
def _set_csrf(self):
with self.app.session_transaction() as sess:
@ -128,7 +136,6 @@ class EndpointTestCase(unittest.TestCase):
url = url_for(resource_name, **kwargs)
if with_csrf:
url = self._add_csrf(url)
post_data = None
if form:
post_data = form
@ -310,6 +317,8 @@ class WebEndpointTestCase(EndpointTestCase):
self.getResponse("web.receipt", expected_code=404) # Will 401 if no user.
def test_request_authorization_code(self):
self.login("devtable", "password")
# Try for an invalid client.
self.getResponse(
"web.request_authorization_code",
@ -332,6 +341,101 @@ class WebEndpointTestCase(EndpointTestCase):
expected_code=200,
)
def test_request_authorization_code_assignment_id_with_code(self):
self.login("devtable", "password")
org = model.organization.get_organization("buynlarge")
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
self.getResponse(
"web.request_authorization_code",
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="repo:read",
assignment_uuid="assignmentid",
response_type="code",
expected_code=400,
)
def test_request_authorization_code_not_org_admin(self):
self.login("devtable", "password")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
self.getResponse(
"web.request_authorization_code",
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="repo:read",
expected_code=403,
)
def test_request_authorization_code_assigned_authorization(self):
self.login("devtable", "password")
devtable = get_user("devtable")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assignment = model.oauth.assign_token_to_user(
app, devtable, app.redirect_uri, "repo:read", "token"
)
response = self.getResponse(
"web.request_authorization_code",
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="repo:read",
assignment_uuid=assignment.uuid,
response_type="token",
expected_code=200,
)
assert "Are you sure you want to authorize this application?" in str(response)
def test_request_authorization_code_assigned_authorization_disabled(self):
self.login("devtable", "password")
devtable = get_user("devtable")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assignment = model.oauth.assign_token_to_user(
app, devtable, app.redirect_uri, "repo:read", "token"
)
with toggle_feature("ASSIGN_OAUTH_TOKEN", False):
self.getResponse(
"web.request_authorization_code",
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="repo:read",
assignment_uuid=assignment.uuid,
response_type="token",
expected_code=400,
)
def test_request_authorization_code_assigned_authorization_with_existing_scopes(self):
self.login("devtable", "password")
devtable = get_user("devtable")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assignment = model.oauth.assign_token_to_user(
app, devtable, app.redirect_uri, "repo:read", "token"
)
create_user_access_token(devtable, app.client_id, "repo:read")
oauth_tokens = list(model.oauth.list_access_tokens_for_user(devtable))
filtered_tokens = [token for token in oauth_tokens if token.application == app]
assert len(filtered_tokens) == 1
self.getResponse(
"web.request_authorization_code",
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="repo:read",
assignment_uuid=assignment.uuid,
response_type="token",
expected_code=302,
)
assert model.oauth.get_assigned_authorization_for_user(devtable, assignment.uuid) is None
oauth_tokens = list(model.oauth.list_access_tokens_for_user(devtable))
filtered_tokens = [token for token in oauth_tokens if token.application == app]
assert len(filtered_tokens) == 2
def test_build_status_badge(self):
# Try for an invalid repository.
self.getResponse("web.build_status_badge", repository="foo/bar", expected_code=404)
@ -526,6 +630,101 @@ class OAuthTestCase(EndpointTestCase):
expected_code=401,
)
def test_authorize_application_assignment_id_with_code(self):
# Note: Defined in initdb.py
form = {
"client_id": "deadbeef",
"redirect_uri": "http://localhost:8000/o2c.html",
"scope": "user:admin",
"assignment_uuid": "assignmentid",
"response_type": "code",
}
headers = dict(authorization=gen_basic_auth("devtable", "password"))
self.postResponse(
"web.authorize_application",
headers=headers,
form=form,
with_csrf=True,
expected_code=400,
)
def test_authorize_application_not_org_admin(self):
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
form = {
"client_id": app.client_id,
"redirect_uri": app.redirect_uri,
"scope": "user:admin",
"assignment_uuid": "assignmentid",
"response_type": "token",
}
headers = dict(authorization=gen_basic_auth("devtable", "password"))
response = self.postResponse(
"web.authorize_application",
headers=headers,
form=form,
with_csrf=True,
expected_code=302,
)
assert "unauthorized_client" in response.headers["Location"]
def test_authorize_application_assigned_authorization(self):
devtable = get_user("devtable")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assignment = model.oauth.assign_token_to_user(
app, devtable, app.redirect_uri, "repo:read", "token"
)
tokens = list(model.oauth.list_access_tokens_for_user(devtable))
assert len(tokens) == 1
form = {
"client_id": app.client_id,
"redirect_uri": app.redirect_uri,
"scope": "user:admin",
"assignment_uuid": assignment.uuid,
"response_type": "token",
}
headers = dict(authorization=gen_basic_auth("devtable", "password"))
response = self.postResponse(
"web.authorize_application",
headers=headers,
form=form,
with_csrf=True,
expected_code=302,
)
assert len(list(model.oauth.list_access_tokens_for_user(devtable))) == 2
assert model.oauth.get_assigned_authorization_for_user(devtable, assignment.uuid) is None
def test_authorize_application_assigned_authorization_disabled(self):
devtable = get_user("devtable")
user = get_user("randomuser")
org = model.organization.create_organization("testorg", "testorg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assignment = model.oauth.assign_token_to_user(
app, devtable, app.redirect_uri, "repo:read", "token"
)
form = {
"client_id": app.client_id,
"redirect_uri": app.redirect_uri,
"scope": "user:admin",
"assignment_uuid": assignment.uuid,
"response_type": "token",
}
headers = dict(authorization=gen_basic_auth("devtable", "password"))
with toggle_feature("ASSIGN_OAUTH_TOKEN", False):
self.postResponse(
"web.authorize_application",
headers=headers,
form=form,
with_csrf=True,
expected_code=400,
)
@parameterized.expand(["token", "code"])
def test_authorize_nocsrf_correctheader(self, response_type):
# Note: Defined in initdb.py
@ -956,5 +1155,116 @@ class KeyServerTestCase(EndpointTestCase):
)
class AssignOauthAppTestCase(EndpointTestCase):
def test_assign_user(self):
self.login("devtable", "password")
assigned_user = get_user("randomuser")
org = get_organization("buynlarge")
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
assigned_auths = model.oauth.list_assigned_authorizations_for_user(assigned_user)
assert len(assigned_auths) == 0
notifications = list_notifications(assigned_user)
assert len(notifications) == 0
response = self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=200,
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="user:admin",
username="randomuser",
response_type="token",
)
assert "Token assigned successfully" in response.data.decode("utf-8")
assigned_auths = model.oauth.list_assigned_authorizations_for_user(assigned_user)
assert len(assigned_auths) == 1
assert assigned_auths[0].application == app
notifications = list_notifications(assigned_user)
assert len(notifications) == 1
assert (
notifications[0].kind
== NotificationKind.select()
.where(NotificationKind.name == "assigned_authorization")
.get()
)
def test_assign_user_unauthenticated(self):
org = get_organization("buynlarge")
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=401,
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="user:admin",
username="randomuser",
response_type="token",
)
def test_assign_user_user_does_not_exist(self):
self.login("devtable", "password")
org = get_organization("buynlarge")
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=404,
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="user:admin",
username="doesnotexist",
response_type="token",
)
def test_assign_user_app_does_not_exist(self):
self.login("devtable", "password")
self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=404,
client_id="doesnotexist",
redirect_uri="http://foo/bar/baz",
scope="user:admin",
username="randomuser",
response_type="token",
)
def test_assign_user_not_org_admin(self):
self.login("devtable", "password")
user = get_user("randomuser")
org = create_organization("neworg", "neworg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=403,
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="user:admin",
username="freshuser",
response_type="token",
)
def test_assign_user_disabled(self):
self.login("devtable", "password")
user = get_user("randomuser")
org = create_organization("neworg", "neworg@devtable.com", user)
app = model.oauth.create_application(org, "test", "http://foo/bar", "http://foo/bar/baz")
with toggle_feature("ASSIGN_OAUTH_TOKEN", False):
self.postResponse(
"web.assign_user_to_app",
with_csrf=True,
expected_code=404,
client_id=app.client_id,
redirect_uri=app.redirect_uri,
scope="user:admin",
username="freshuser",
response_type="token",
)
if __name__ == "__main__":
unittest.main()

View File

@ -1531,5 +1531,10 @@ CONFIG_SCHEMA = {
"description": "Set minimal security level for new notifications on detected vulnerabilities. Avoids creation of large number of notifications after first index.",
"x-example": "High",
},
"FEATURE_ASSIGN_OAUTH_TOKEN": {
"type": "boolean",
"description": "Allows organization administrators to assign OAuth tokens to other users",
"x-example": False,
},
},
}

View File

@ -89,6 +89,8 @@ ALTER TABLE IF EXISTS ONLY public.permissionprototype DROP CONSTRAINT IF EXISTS
ALTER TABLE IF EXISTS ONLY public.organizationrhskus DROP CONSTRAINT IF EXISTS fk_organizationrhskus_userid;
ALTER TABLE IF EXISTS ONLY public.organizationrhskus DROP CONSTRAINT IF EXISTS fk_organizationrhskus_orgid;
ALTER TABLE IF EXISTS ONLY public.oauthauthorizationcode DROP CONSTRAINT IF EXISTS fk_oauthauthorizationcode_application_id_oauthapplication;
ALTER TABLE IF EXISTS ONLY public.oauthassignedtoken DROP CONSTRAINT IF EXISTS fk_oauthassignedtoken_assigned_user_user;
ALTER TABLE IF EXISTS ONLY public.oauthassignedtoken DROP CONSTRAINT IF EXISTS fk_oauthassignedtoken_application_oauthapplication;
ALTER TABLE IF EXISTS ONLY public.oauthapplication DROP CONSTRAINT IF EXISTS fk_oauthapplication_organization_id_user;
ALTER TABLE IF EXISTS ONLY public.oauthaccesstoken DROP CONSTRAINT IF EXISTS fk_oauthaccesstoken_authorized_user_id_user;
ALTER TABLE IF EXISTS ONLY public.oauthaccesstoken DROP CONSTRAINT IF EXISTS fk_oauthaccesstoken_application_id_oauthapplication;
@ -319,6 +321,9 @@ DROP INDEX IF EXISTS public.organizationrhskus_subscription_id_org_id;
DROP INDEX IF EXISTS public.organizationrhskus_subscription_id;
DROP INDEX IF EXISTS public.oauthauthorizationcode_code_name;
DROP INDEX IF EXISTS public.oauthauthorizationcode_application_id;
DROP INDEX IF EXISTS public.oauthassignedtoken_uuid;
DROP INDEX IF EXISTS public.oauthassignedtoken_assigned_user;
DROP INDEX IF EXISTS public.oauthassignedtoken_application_id;
DROP INDEX IF EXISTS public.oauthapplication_organization_id;
DROP INDEX IF EXISTS public.oauthapplication_client_id;
DROP INDEX IF EXISTS public.oauthaccesstoken_uuid;
@ -539,6 +544,7 @@ ALTER TABLE IF EXISTS ONLY public.proxycacheconfig DROP CONSTRAINT IF EXISTS pk_
ALTER TABLE IF EXISTS ONLY public.permissionprototype DROP CONSTRAINT IF EXISTS pk_permissionprototype;
ALTER TABLE IF EXISTS ONLY public.organizationrhskus DROP CONSTRAINT IF EXISTS pk_organizationrhskus;
ALTER TABLE IF EXISTS ONLY public.oauthauthorizationcode DROP CONSTRAINT IF EXISTS pk_oauthauthorizationcode;
ALTER TABLE IF EXISTS ONLY public.oauthassignedtoken DROP CONSTRAINT IF EXISTS pk_oauthassignedtoken;
ALTER TABLE IF EXISTS ONLY public.oauthapplication DROP CONSTRAINT IF EXISTS pk_oauthapplication;
ALTER TABLE IF EXISTS ONLY public.oauthaccesstoken DROP CONSTRAINT IF EXISTS pk_oauthaccesstoken;
ALTER TABLE IF EXISTS ONLY public.notificationkind DROP CONSTRAINT IF EXISTS pk_notificationkind;
@ -645,6 +651,7 @@ ALTER TABLE IF EXISTS public.proxycacheconfig ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.permissionprototype ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.organizationrhskus ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.oauthauthorizationcode ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.oauthassignedtoken ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.oauthapplication ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.oauthaccesstoken ALTER COLUMN id DROP DEFAULT;
ALTER TABLE IF EXISTS public.notificationkind ALTER COLUMN id DROP DEFAULT;
@ -804,6 +811,8 @@ DROP SEQUENCE IF EXISTS public.organizationrhskus_id_seq;
DROP TABLE IF EXISTS public.organizationrhskus;
DROP SEQUENCE IF EXISTS public.oauthauthorizationcode_id_seq;
DROP TABLE IF EXISTS public.oauthauthorizationcode;
DROP SEQUENCE IF EXISTS public.oauthassignedtoken_id_seq;
DROP TABLE IF EXISTS public.oauthassignedtoken;
DROP SEQUENCE IF EXISTS public.oauthapplication_id_seq;
DROP TABLE IF EXISTS public.oauthapplication;
DROP SEQUENCE IF EXISTS public.oauthaccesstoken_id_seq;
@ -2836,6 +2845,45 @@ ALTER TABLE public.oauthapplication_id_seq OWNER TO quay;
ALTER SEQUENCE public.oauthapplication_id_seq OWNED BY public.oauthapplication.id;
--
-- Name: oauthassignedtoken; Type: TABLE; Schema: public; Owner: quay
--
CREATE TABLE public.oauthassignedtoken (
id integer NOT NULL,
uuid character varying(255) NOT NULL,
assigned_user_id integer NOT NULL,
application_id integer NOT NULL,
redirect_uri character varying(255),
scope character varying(255) NOT NULL,
response_type character varying(255)
);
ALTER TABLE public.oauthassignedtoken OWNER TO quay;
--
-- Name: oauthassignedtoken_id_seq; Type: SEQUENCE; Schema: public; Owner: quay
--
CREATE SEQUENCE public.oauthassignedtoken_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.oauthassignedtoken_id_seq OWNER TO quay;
--
-- Name: oauthassignedtoken_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quay
--
ALTER SEQUENCE public.oauthassignedtoken_id_seq OWNED BY public.oauthassignedtoken.id;
--
-- Name: oauthauthorizationcode; Type: TABLE; Schema: public; Owner: quay
--
@ -5226,6 +5274,13 @@ ALTER TABLE ONLY public.oauthaccesstoken ALTER COLUMN id SET DEFAULT nextval('pu
ALTER TABLE ONLY public.oauthapplication ALTER COLUMN id SET DEFAULT nextval('public.oauthapplication_id_seq'::regclass);
--
-- Name: oauthassignedtoken id; Type: DEFAULT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.oauthassignedtoken ALTER COLUMN id SET DEFAULT nextval('public.oauthassignedtoken_id_seq'::regclass);
--
-- Name: oauthauthorizationcode id; Type: DEFAULT; Schema: public; Owner: quay
--
@ -5627,7 +5682,7 @@ COPY public.accesstokenkind (id, name) FROM stdin;
--
COPY public.alembic_version (version_num) FROM stdin;
0cdd1f27a450
0988213e0885
\.
@ -6498,6 +6553,7 @@ COPY public.notificationkind (id, name) FROM stdin;
16 repo_mirror_sync_failed
17 quota_warning
18 quota_error
19 assigned_authorization
\.
@ -6517,6 +6573,14 @@ COPY public.oauthapplication (id, client_id, redirect_uri, application_uri, orga
\.
--
-- Data for Name: oauthassignedtoken; Type: TABLE DATA; Schema: public; Owner: quay
--
COPY public.oauthassignedtoken (id, uuid, assigned_user_id, application_id, redirect_uri, scope, response_type) FROM stdin;
\.
--
-- Data for Name: oauthauthorizationcode; Type: TABLE DATA; Schema: public; Owner: quay
--
@ -8248,7 +8312,7 @@ SELECT pg_catalog.setval('public.notification_id_seq', 2, true);
-- Name: notificationkind_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.notificationkind_id_seq', 18, true);
SELECT pg_catalog.setval('public.notificationkind_id_seq', 19, true);
--
@ -8265,6 +8329,13 @@ SELECT pg_catalog.setval('public.oauthaccesstoken_id_seq', 1, false);
SELECT pg_catalog.setval('public.oauthapplication_id_seq', 1, false);
--
-- Name: oauthassignedtoken_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.oauthassignedtoken_id_seq', 1, false);
--
-- Name: oauthauthorizationcode_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
@ -9059,6 +9130,14 @@ ALTER TABLE ONLY public.oauthapplication
ADD CONSTRAINT pk_oauthapplication PRIMARY KEY (id);
--
-- Name: oauthassignedtoken pk_oauthassignedtoken; Type: CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.oauthassignedtoken
ADD CONSTRAINT pk_oauthassignedtoken PRIMARY KEY (id);
--
-- Name: oauthauthorizationcode pk_oauthauthorizationcode; Type: CONSTRAINT; Schema: public; Owner: quay
--
@ -10653,6 +10732,27 @@ CREATE INDEX oauthapplication_client_id ON public.oauthapplication USING btree (
CREATE INDEX oauthapplication_organization_id ON public.oauthapplication USING btree (organization_id);
--
-- Name: oauthassignedtoken_application_id; Type: INDEX; Schema: public; Owner: quay
--
CREATE INDEX oauthassignedtoken_application_id ON public.oauthassignedtoken USING btree (application_id);
--
-- Name: oauthassignedtoken_assigned_user; Type: INDEX; Schema: public; Owner: quay
--
CREATE INDEX oauthassignedtoken_assigned_user ON public.oauthassignedtoken USING btree (assigned_user_id);
--
-- Name: oauthassignedtoken_uuid; Type: INDEX; Schema: public; Owner: quay
--
CREATE UNIQUE INDEX oauthassignedtoken_uuid ON public.oauthassignedtoken USING btree (uuid);
--
-- Name: oauthauthorizationcode_application_id; Type: INDEX; Schema: public; Owner: quay
--
@ -12318,6 +12418,22 @@ ALTER TABLE ONLY public.oauthapplication
ADD CONSTRAINT fk_oauthapplication_organization_id_user FOREIGN KEY (organization_id) REFERENCES public."user"(id);
--
-- Name: oauthassignedtoken fk_oauthassignedtoken_application_oauthapplication; Type: FK CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.oauthassignedtoken
ADD CONSTRAINT fk_oauthassignedtoken_application_oauthapplication FOREIGN KEY (application_id) REFERENCES public.oauthapplication(id);
--
-- Name: oauthassignedtoken fk_oauthassignedtoken_assigned_user_user; Type: FK CONSTRAINT; Schema: public; Owner: quay
--
ALTER TABLE ONLY public.oauthassignedtoken
ADD CONSTRAINT fk_oauthassignedtoken_assigned_user_user FOREIGN KEY (assigned_user_id) REFERENCES public."user"(id);
--
-- Name: oauthauthorizationcode fk_oauthauthorizationcode_application_id_oauthapplication; Type: FK CONSTRAINT; Schema: public; Owner: quay
--