Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSM Teams integration #5575

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
name: Run yarn test
command: |
cd ${CIRCLE_WORKING_DIRECTORY}/frontend/
CI=true yarn test -w 1
CI=true REACT_APP_OSM_TEAMS_CLIENT_ID=boo yarn test -w 1
CI=true GENERATE_SOURCEMAP=false yarn build

backend-code-check-PEP8:
Expand Down
13 changes: 13 additions & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ def format_url(endpoint):
scope=EnvironmentConfig.OAUTH_SCOPE,
redirect_uri=EnvironmentConfig.OAUTH_REDIRECT_URI,
)
osm_teams = OAuth2Session(
client_id=EnvironmentConfig.OSM_TEAMS_CLIENT_ID,
scope="openid offline",
)

# Import all models so that they are registered with SQLAlchemy
from backend.models.postgis import * # noqa
Expand Down Expand Up @@ -375,6 +379,8 @@ def add_api_endpoints(app):
SystemAuthenticationEmailAPI,
SystemAuthenticationLoginAPI,
SystemAuthenticationCallbackAPI,
OSMTeamsAuthenticationCallbackAPI,
OSMTeamsAuthenticationAPI,
)
from backend.api.system.applications import SystemApplicationsRestAPI
from backend.api.system.image_upload import SystemImageUploadRestAPI
Expand Down Expand Up @@ -923,9 +929,16 @@ def add_api_endpoints(app):
api.add_resource(
SystemAuthenticationLoginAPI, format_url("system/authentication/login/")
)
api.add_resource(
OSMTeamsAuthenticationAPI, format_url("system/osm-teams-authentication/login/")
)
api.add_resource(
SystemAuthenticationCallbackAPI, format_url("system/authentication/callback/")
)
api.add_resource(
OSMTeamsAuthenticationCallbackAPI,
format_url("system/osm-teams-authentication/callback/"),
)
api.add_resource(
SystemAuthenticationEmailAPI, format_url("system/authentication/email/")
)
Expand Down
92 changes: 91 additions & 1 deletion backend/api/system/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from flask_restful import Resource
from oauthlib.oauth2.rfc6749.errors import InvalidGrantError

from backend import osm
from backend import osm, osm_teams
from backend.config import EnvironmentConfig
from backend.services.users.authentication_service import (
AuthenticationService,
Expand Down Expand Up @@ -43,6 +43,33 @@ def get(self):
return {"auth_url": login_url, "state": state}, 200


class OSMTeamsAuthenticationAPI(Resource):
def get(self):
"""
Returns URL to allow authentication in OSM Teams
---
tags:
- system
produces:
- application/json
parameters:
- in: query
name: redirect_uri
description: Route to redirect user once authenticated
type: string
default: /take/me/here
responses:
200:
description: oauth2 params
"""
state = AuthenticationService.generate_random_state()
osm_teams.state = state
login_url, state = osm_teams.authorization_url(
EnvironmentConfig.OSM_TEAMS_AUTH_URL
)
return {"auth_url": login_url, "state": state}, 200


class SystemAuthenticationCallbackAPI(Resource):
def get(self):
"""
Expand Down Expand Up @@ -126,6 +153,69 @@ def get(self):
return {"Error": "Unable to authenticate", "SubCode": "AuthError"}, 500


class OSMTeamsAuthenticationCallbackAPI(Resource):
def get(self):
"""
Handles the OSM Teams OAuth callback
---
tags:
- system
produces:
- application/json
parameters:
- in: query
name: redirect_uri
description: Route to redirect user once authenticated
type: string
default: /take/me/here
required: false
- in: query
name: code
description: Code obtained after user authorization
type: string
required: true
- in: query
name: email_address
description: Email address to used for email notifications from TM.
type: string
required: false
responses:
302:
description: Redirects to login page, or login failed page
500:
description: A problem occurred authenticating the user
502:
description: A problem occurred negotiating with the OSM API
"""

authorization_code = request.args.get("code", None)
if authorization_code is None:
return {"Subcode": "InvalidData", "Error": "Missing code parameter"}, 500

try:
osm_teams_response = osm_teams.fetch_token(
token_url=EnvironmentConfig.OSM_TEAMS_TOKEN_URL,
client_secret=EnvironmentConfig.OSM_TEAMS_CLIENT_SECRET,
code=authorization_code,
)
except InvalidGrantError:
return {
"Error": "The provided authorization grant is invalid, expired or revoked",
"SubCode": "InvalidGrantError",
}, 400
if osm_teams_response is None:
current_app.logger.critical("Couldn't obtain token from OSM Teams.")
return {
"Subcode": "TokenFetchError",
"Error": "Couldn't fetch token from OSM Teams.",
}, 502

try:
return osm_teams_response, 200
except AuthServiceError:
return {"Error": "Unable to authenticate", "SubCode": "AuthError"}, 500


class SystemAuthenticationEmailAPI(Resource):
def get(self):
"""
Expand Down
5 changes: 5 additions & 0 deletions backend/api/teams/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def patch(self, team_id):
name:
type: string
default: HOT - Mappers
osm_teams_id:
type: integer
logo:
type: string
default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg
Expand Down Expand Up @@ -327,6 +329,8 @@ def post(self):
organisation_id:
type: integer
default: 1
osm_teams_id:
type: integer
description:
type: string
visibility:
Expand All @@ -340,6 +344,7 @@ def post(self):
- "ANY"
- "BY_REQUEST"
- "BY_INVITE"
- "OSM_TEAMS"
responses:
201:
description: Team created successfully
Expand Down
11 changes: 11 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ class EnvironmentConfig:
# Sentry backend DSN
SENTRY_BACKEND_DSN = os.getenv("TM_SENTRY_BACKEND_DSN", None)

# OSM Teams
OSM_TEAMS_CLIENT_ID = os.getenv("OSM_TEAMS_CLIENT_ID", None)
OSM_TEAMS_CLIENT_SECRET = os.getenv("OSM_TEAMS_CLIENT_SECRET", None)
OSM_TEAMS_AUTH_DOMAIN = os.getenv("OSM_TEAMS_AUTH_DOMAIN", None)
OSM_TEAMS_TOKEN_DOMAIN = os.getenv("OSM_TEAMS_TOKEN_DOMAIN", OSM_TEAMS_AUTH_DOMAIN)
OSM_TEAMS_AUTH_PATH = os.getenv("OSM_TEAMS_AUTH_PATH", "/hyauth/oauth2/auth")
OSM_TEAMS_TOKEN_PATH = os.getenv("OSM_TEAMS_TOKEN_PATH", "/hyauth/oauth2/token")
OSM_TEAMS_AUTH_URL = f"{OSM_TEAMS_AUTH_DOMAIN}{OSM_TEAMS_AUTH_PATH}"
OSM_TEAMS_TOKEN_URL = f"{OSM_TEAMS_TOKEN_DOMAIN}{OSM_TEAMS_TOKEN_PATH}"
OSM_TEAMS_API_URL = os.getenv("OSM_TEAMS_API_URL", None)


class TestEnvironmentConfig(EnvironmentConfig):
POSTGRES_TEST_DB = os.getenv("POSTGRES_TEST_DB", None)
Expand Down
5 changes: 5 additions & 0 deletions backend/models/dtos/team_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def validate_team_join_method(value):
f"{TeamJoinMethod.ANY.name}, "
f"{TeamJoinMethod.BY_INVITE.name}, "
f"{TeamJoinMethod.BY_REQUEST.name}"
f"{TeamJoinMethod.OSM_TEAMS.name}"
)


Expand Down Expand Up @@ -92,6 +93,7 @@ def __init__(self):
""" Describes JSON model for a team """
team_id = IntType(serialized_name="teamId")
organisation_id = IntType(required=True)
osm_teams_id = IntType(required=False)
organisation = StringType(required=True)
organisation_slug = StringType(serialized_name="organisationSlug")
name = StringType(required=True)
Expand Down Expand Up @@ -131,6 +133,7 @@ class TeamDTO(Model):
members = ListType(ModelType(TeamMembersDTO))
members_count = IntType(serialized_name="membersCount", required=False)
managers_count = IntType(serialized_name="managersCount", required=False)
osm_teams_id = IntType(required=False)


class TeamsListDTO(Model):
Expand All @@ -150,6 +153,7 @@ class NewTeamDTO(Model):
creator = LongType(required=True)
organisation_id = IntType(required=True)
name = StringType(required=True)
osm_teams_id = IntType()
description = StringType()
join_method = StringType(
required=True,
Expand All @@ -166,6 +170,7 @@ class UpdateTeamDTO(Model):

creator = LongType()
team_id = IntType()
osm_teams_id = IntType()
organisation = StringType()
organisation_id = IntType()
name = StringType()
Expand Down
1 change: 1 addition & 0 deletions backend/models/postgis/statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class TeamJoinMethod(Enum):
ANY = 0
BY_REQUEST = 1
BY_INVITE = 2
OSM_TEAMS = 3


class TeamRoles(Enum):
Expand Down
4 changes: 4 additions & 0 deletions backend/models/postgis/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class Team(db.Model):
visibility = db.Column(
db.Integer, default=TeamVisibility.PUBLIC.value, nullable=False
)
osm_teams_id = db.Column(db.BigInteger, nullable=True)

organisation = db.relationship(Organisation, backref="teams")

Expand All @@ -95,6 +96,7 @@ def create_from_dto(cls, new_team_dto: NewTeamDTO):
new_team.description = new_team_dto.description
new_team.join_method = TeamJoinMethod[new_team_dto.join_method].value
new_team.visibility = TeamVisibility[new_team_dto.visibility].value
new_team.osm_teams_id = new_team_dto.osm_teams_id

org = Organisation.get(new_team_dto.organisation_id)
new_team.organisation = org
Expand Down Expand Up @@ -195,6 +197,7 @@ def as_dto(self):
team_dto.name = self.name
team_dto.organisation = self.organisation.name
team_dto.organisation_id = self.organisation.id
team_dto.osm_teams_id = self.osm_teams_id
team_dto.logo = self.organisation.logo
team_dto.visibility = TeamVisibility(self.visibility).name
return team_dto
Expand All @@ -205,6 +208,7 @@ def as_dto_inside_org(self):
team_dto.team_id = self.id
team_dto.name = self.name
team_dto.description = self.description
team_dto.osm_teams_id = self.osm_teams_id
team_dto.join_method = TeamJoinMethod(self.join_method).name
team_dto.members = self._get_team_members()
team_dto.visibility = TeamVisibility(self.visibility).name
Expand Down
2 changes: 2 additions & 0 deletions backend/services/team_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ def get_all_teams(search_dto: TeamSearchDTO) -> TeamsListDTO:
team_dto.join_method = TeamJoinMethod(team.join_method).name
team_dto.visibility = TeamVisibility(team.visibility).name
team_dto.description = team.description
team_dto.osm_teams_id = team.osm_teams_id
team_dto.logo = team.organisation.logo
team_dto.organisation = team.organisation.name
team_dto.organisation_id = team.organisation.id
Expand Down Expand Up @@ -341,6 +342,7 @@ def get_team_as_dto(
team_dto.join_method = TeamJoinMethod(team.join_method).name
team_dto.visibility = TeamVisibility(team.visibility).name
team_dto.description = team.description
team_dto.osm_teams_id = team.osm_teams_id
team_dto.logo = team.organisation.logo
team_dto.organisation = team.organisation.name
team_dto.organisation_id = team.organisation.id
Expand Down
11 changes: 11 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,14 @@ TM_DEFAULT_LOCALE=en
# Sentry.io DSN Config (optional)
# TM_SENTRY_BACKEND_DSN=https://foo.ingest.sentry.io/1234567
# TM_SENTRY_FRONTEND_DSN=https://bar.ingest.sentry.io/8901234

# OSM Teams
OSM_TEAMS_AUTH_DOMAIN='https://auth.mapping.team'
OSM_TEAMS_AUTH_PATH='/hyauth/oauth2/auth'
# The TOKEN domain only needs to be set if some network restriction blocks getting a token from the AUTH domain
# If it is not configured, TM will use the AUTH domain.
OSM_TEAMS_TOKEN_DOMAIN='https://auth.mapping.team'
OSM_TEAMS_TOKEN_PATH='/hyauth/oauth2/token'
OSM_TEAMS_API_URL='https://mapping.team'
# OSM_TEAMS_CLIENT_ID=foo
# OSM_TEAMS_CLIENT_SECRET=foo
2 changes: 2 additions & 0 deletions frontend/.env.expand
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ REACT_APP_SENTRY_FRONTEND_DSN=$TM_SENTRY_FRONTEND_DSN
REACT_APP_ENVIRONMENT=$TM_ENVIRONMENT
REACT_APP_TM_DEFAULT_CHANGESET_COMMENT=$TM_DEFAULT_CHANGESET_COMMENT
REACT_APP_RAPID_EDITOR_URL=$RAPID_EDITOR_URL
REACT_APP_OSM_TEAMS_API_URL=$OSM_TEAMS_API_URL
REACT_APP_OSM_TEAMS_CLIENT_ID=$OSM_TEAMS_CLIENT_ID
9 changes: 8 additions & 1 deletion frontend/src/components/formInputs.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { formatCountryList } from '../utils/countries';
import { fetchLocalJSONAPI } from '../network/genericJSONRequest';
import { CheckIcon, SearchIcon, CloseIcon } from './svgIcons';

export const RadioField = ({ name, value, className, required = false }: Object) => (
export const RadioField = ({
name,
value,
className,
required = false,
disabled = false,
}: Object) => (
<Field
name={name}
component="input"
Expand All @@ -19,6 +25,7 @@ export const RadioField = ({ name, value, className, required = false }: Object)
className || ''
}`}
required={required}
disabled={disabled}
/>
);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/svgIcons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ export { CutIcon } from './cut';
export { FileImportIcon } from './fileImport';
export { CalendarIcon } from './calendar';
export { CommentIcon } from './comment';
export { UserGroupIcon } from './user-group';
16 changes: 16 additions & 0 deletions frontend/src/components/svgIcons/user-group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
// License: CC-By 4.0
export class UserGroupIcon extends React.PureComponent {
render() {
return (
<svg viewBox="0 0 640 512" {...this.props}>
<path
d="M352 128c0 70.7-57.3 128-128 128s-128-57.3-128-128S153.3 0 224 0s128 57.3 128 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM609.3 512H471.4c5.4-9.4 8.6-20.3 8.6-32v-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2h61.4C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"
fill="currentColor"
/>
</svg>
);
}
}
1 change: 1 addition & 0 deletions frontend/src/components/teamsAndOrgs/management.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function JoinMethodBox(props) {
ANY: 'anyoneCanJoin',
BY_REQUEST: 'byRequest',
BY_INVITE: 'byInvite',
OSM_TEAMS: 'OSMTeams',
};
return (
<div className={`tc br1 f7 ttu ba red b--red ${props.className}`}>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/teamsAndOrgs/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function Members({
setMemberJoinTeamError,
managerJoinTeamError,
setManagerJoinTeamError,
disableEdit,
}: Object) {
const token = useSelector((state) => state.auth.token);
const [editMode, setEditMode] = useState(false);
Expand Down Expand Up @@ -82,8 +83,13 @@ export function Members({
<div className={`bg-white b--grey-light pa4 ${editMode ? 'bt bl br' : 'ba'}`}>
<div className="cf db">
<h3 className="f3 blue-dark mv2 fw6 fl">{title}</h3>
<EditModeControl editMode={editMode} switchModeFn={setEditMode} />
{!disableEdit && <EditModeControl editMode={editMode} switchModeFn={setEditMode} />}
</div>
{disableEdit && (
<div className="blue-grey f6">
<FormattedMessage {...messages.syncedWithOSMTeams} />
</div>
)}
<div className="cf mb1">
{editMode && (
<AsyncSelect
Expand Down