Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/sentry/api/bases/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

from sentry.api.base import Endpoint
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import ScopedPermission
from sentry.models import User
from sentry.api.permissions import SentryPermission
from sentry.models import Organization, OrganizationStatus, User
from sentry.auth.superuser import is_active_superuser


class UserPermission(ScopedPermission):
class UserPermission(SentryPermission):
def has_object_permission(self, request, view, user=None):
if user is None:
user = request.user
Expand All @@ -20,6 +20,36 @@ def has_object_permission(self, request, view, user=None):
return False


class OrganizationUserPermission(UserPermission):
scope_map = {
'DELETE': ['member:admin'],
}

def has_org_permission(self, request, user):
"""
Org can act on a user account,
if the user is a member of only one org
e.g. reset org member's 2FA
"""

try:
organization = Organization.objects.get(
status=OrganizationStatus.VISIBLE,
member_set__user=user
)

self.determine_access(request, organization)
allowed_scopes = set(self.scope_map.get(request.method, []))
return any(request.access.has_scope(s) for s in allowed_scopes)
except (Organization.DoesNotExist, Organization.MultipleObjectsReturned):
return False

def has_object_permission(self, request, view, user=None):
if super(OrganizationUserPermission, self).has_object_permission(request, view, user):
return True
return self.has_org_permission(request, user)


class UserEndpoint(Endpoint):
permission_classes = (UserPermission, )

Expand Down
4 changes: 3 additions & 1 deletion src/sentry/api/endpoints/organization_member_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from sentry.api.bases.organization import (
OrganizationEndpoint, OrganizationPermission)
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.serializers import serialize, RoleSerializer, OrganizationMemberWithTeamsSerializer
from sentry.api.serializers import (
DetailedUserSerializer, serialize, RoleSerializer, OrganizationMemberWithTeamsSerializer)
from sentry.api.serializers.rest_framework import ListField
from sentry.auth.superuser import is_active_superuser
from sentry.models import (
Expand Down Expand Up @@ -105,6 +106,7 @@ def _serialize_member(self, member, request, allowed_roles=None):

if request.access.has_scope('member:admin'):
context['invite_link'] = member.get_invite_link()
context['user'] = serialize(member.user, request.user, DetailedUserSerializer())

context['isOnlyOwner'] = self._is_only_owner(member)
context['roles'] = serialize(
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/api/endpoints/user_authenticator_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
from rest_framework import status
from rest_framework.response import Response

from sentry.api.bases.user import UserEndpoint
from sentry.api.bases.user import UserEndpoint, OrganizationUserPermission
from sentry.api.decorators import sudo_required
from sentry.api.serializers import serialize
from sentry.models import Authenticator
from sentry.security import capture_security_activity


class UserAuthenticatorDetailsEndpoint(UserEndpoint):
permission_classes = (OrganizationUserPermission, )

@sudo_required
def get(self, request, user, auth_id):
"""
Expand Down
11 changes: 11 additions & 0 deletions src/sentry/api/serializers/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from sentry.models import (
AuthIdentity,
Authenticator,
OrganizationMember,
OrganizationStatus,
User,
UserAvatar,
UserOption,
Expand Down Expand Up @@ -150,10 +152,18 @@ def get_attrs(self, item_list, user):
user__in=item_list,
), 'user_id')

memberships = manytoone_to_dict(OrganizationMember.objects.filter(
user__in=item_list,
organization__status=OrganizationStatus.VISIBLE,
), 'user_id')

for item in item_list:
attrs[item]['authenticators'] = authenticators[item.id]
attrs[item]['permissions'] = permissions[item.id]

# org can reset 2FA if the user is only in one org
attrs[item]['canReset2fa'] = len(memberships[item.id]) == 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do users with more than one org get this flag to be true? Should it be true if the user is viewing themself?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm I went with no, the user shouldn't be able to reset their 2FA from this page. I think users can only read the member details page, but it takes manager and up permissions to take any action. Thought it would be more consistent to keep this page for managing members, since users can remove their 2FA in user settings. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me.


return attrs

def serialize(self, obj, attrs, user):
Expand All @@ -174,4 +184,5 @@ def serialize(self, obj, attrs, user):
'dateUsed': a.last_used_at,
} for a in attrs['authenticators']
]
d['canReset2fa'] = attrs['canReset2fa']
return d
6 changes: 6 additions & 0 deletions src/sentry/static/sentry/app/actionCreators/account.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ export function logout(api) {
method: 'DELETE',
});
}

export function removeAuthenticator(api, userId, authId) {
return api.requestPromise(`/users/${userId}/authenticators/${authId}/`, {
method: 'DELETE',
});
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import * as Sentry from '@sentry/browser';

import {browserHistory} from 'react-router';
import React from 'react';

import {resendMemberInvite, updateMember} from 'app/actionCreators/members';
import {t} from 'app/locale';
import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
import {t, tct} from 'app/locale';
import AsyncView from 'app/views/asyncView';
import Button from 'app/components/button';
import Confirm from 'app/components/confirm';
import DateTime from 'app/components/dateTime';
import Field from 'app/views/settings/components/forms/field';
import IndicatorStore from 'app/stores/indicatorStore';
import NotFound from 'app/components/errors/notFound';
import {Panel, PanelBody, PanelHeader, PanelItem} from 'app/components/panels';
import {removeAuthenticator} from 'app/actionCreators/account';
import SentryTypes from 'app/sentryTypes';
import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
import Tooltip from 'app/components/tooltip';
import recreateRoute from 'app/utils/recreateRoute';

import RoleSelect from './inviteMember/roleSelect';
import TeamSelect from './inviteMember/teamSelect';

const NOT_ENROLLED = t('Not enrolled in two-factor authentication');
const NO_PERMISSION = t('You do not have permission to perform this action');
const TWO_FACTOR_REQUIRED = t(
'Cannot be reset since two-factor is required for this organization'
);
const MULTIPLE_ORGS = t('Cannot be reset since user is in more than one organization');

class OrganizationMemberDetail extends AsyncView {
static contextTypes = {
organization: SentryTypes.Organization,
Expand Down Expand Up @@ -142,16 +156,62 @@ class OrganizationMemberDetail extends AsyncView {
});
};

handle2faReset = () => {
let {user} = this.state.member;
let {slug} = this.getOrganization();

let requests = user.authenticators.map(auth =>
removeAuthenticator(this.api, user.id, auth.id)
);

Promise.all(requests)
.then(() => {
this.props.router.push(`/settings/${slug}/members/`);
addSuccessMessage(t('All authenticators have been removed'));
})
.catch(err => {
addErrorMessage(t('Error removing authenticators'));
Sentry.captureException(err);
});
};

showResetButton = () => {
let {member} = this.state;
let {require2FA} = this.getOrganization();
let {user} = member;

if (!user || !user.authenticators || require2FA) return false;
let hasAuth = user.authenticators.length >= 1;
return hasAuth && user.canReset2fa;
};

getTooltip = () => {
let {member} = this.state;
let {require2FA} = this.getOrganization();
let {user} = member;

if (!user) return '';

if (!user.authenticators) return NO_PERMISSION;
if (!user.authenticators.length) return NOT_ENROLLED;
if (!user.canReset2fa) return MULTIPLE_ORGS;
if (require2FA) return TWO_FACTOR_REQUIRED;

return '';
};

renderBody() {
let {error, member} = this.state;
let {teams, access} = this.getOrganization();

if (!member) return <NotFound />;

let email = member.email;
let inviteLink = member.invite_link;
let canEdit = access.includes('org:write');
let canResend = !member.expired;

let {email, expired, pending} = member;
let canResend = !expired;
let showAuth = !pending;

return (
<div>
Expand Down Expand Up @@ -246,6 +306,44 @@ class OrganizationMemberDetail extends AsyncView {
</PanelBody>
</Panel>

{showAuth && (
<Panel>
<PanelHeader>{t('Authentication')}</PanelHeader>
<PanelBody>
<Field
alignRight={true}
flexibleControlStateSize={true}
label={t('Reset two-factor authentication')}
help={t(
'Resetting two-factor authentication will remove all two-factor authentication methods for this member.'
)}
>
<Tooltip
data-test-id="reset-2fa-tooltip"
disabled={this.showResetButton()}
title={this.getTooltip()}
>
<span>
<Confirm
disabled={!this.showResetButton()}
message={tct(
'Are you sure you want to disable all two-factor authentication methods for [name]?',
{name: member.name ? member.name : 'this member'}
)}
onConfirm={this.handle2faReset}
data-test-id="reset-2fa-confirm"
>
<Button data-test-id="reset-2fa" priority="danger">
{t('Reset two-factor authentication')}
</Button>
</Confirm>
</span>
</Tooltip>
</Field>
</PanelBody>
</Panel>
)}

<RoleSelect
enforceAllowed={false}
disabled={!canEdit}
Expand Down
Loading