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

Roles refactor: New public API with reworked project roles #337

Merged
merged 6 commits into from
Dec 10, 2024
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
1 change: 0 additions & 1 deletion server/mergin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ def create_app(public_keys: List[str] = None) -> Flask:
arguments={"title": "Mergin"},
options={"swagger_ui": Configuration.SWAGGER_UI},
validate_responses=True,
pythonic_params=True,
MarcelGeo marked this conversation as resolved.
Show resolved Hide resolved
)
app.add_api(
"sync/private_api.yaml",
Expand Down
53 changes: 53 additions & 0 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ tags:
description: Mergin user
- name: admin
description: For mergin admin
- name: public
description: Public API
paths:
/app/auth/user/search:
get:
Expand Down Expand Up @@ -620,6 +622,57 @@ paths:
$ref: "#/components/responses/UnauthorizedError"
"403":
$ref: "#/components/responses/Forbidden"
/v2/users:
post:
tags:
- user
- public
summary: Create user
operationId: create_user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
- workspace_id
- role
properties:
email:
type: string
format: email
example: john.doe@example.com
username:
type: string
example: john.doe
password:
type: string
format: password
example: topsecret
workspace_id:
type: integer
example: 1
role:
$ref: "#/components/schemas/WorkspaceRole"
notify_user:
type: boolean
responses:
"201":
description: User info
content:
application/json:
schema:
$ref: "#/components/schemas/UserInfo"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
$ref: "#/components/responses/NotFoundResp"
"422":
$ref: "#/components/responses/UnprocessableEntity"
x-openapi-router-controller: mergin.auth.controller
components:
responses:
UnauthorizedError:
Expand Down
19 changes: 8 additions & 11 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from .commands import add_commands
from .config import Configuration
from .models import User, UserProfile
from ..app import db

# signal for other versions to listen to
user_account_closed = signal("user_account_closed")
Expand Down Expand Up @@ -97,24 +96,22 @@ def confirm_token(token, expiration=3600 * 24 * 3):
return email


def send_confirmation_email(app, user, url, template, header):
def send_confirmation_email(app, user, url, template, header, **kwargs):
MarcelGeo marked this conversation as resolved.
Show resolved Hide resolved
"""
Send confirmation email from selected template with customizable email subject and confirmation URL.
Optional kwargs are passed to render_template method if needed for particular template.
"""
from ..celery import send_email_async

token = generate_confirmation_token(app, user.email)
confirm_url = f"{url}/{token}"
html = render_template(template, subject=header, confirm_url=confirm_url, user=user)
html = render_template(
template, subject=header, confirm_url=confirm_url, user=user, **kwargs
)
email_data = {
"subject": header,
"html": html,
"recipients": [user.email],
"sender": app.config["MAIL_DEFAULT_SENDER"],
}
send_email_async.delay(**email_data)


def do_register_user(username, email, password):
user = User(username.strip(), email.strip(), password, False)
user.profile = UserProfile()
db.session.add(user)
db.session.commit()
return user
49 changes: 45 additions & 4 deletions server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from .app import (
auth_required,
authenticate,
do_register_user,
send_confirmation_email,
confirm_token,
generate_confirmation_token,
Expand Down Expand Up @@ -91,7 +90,7 @@ def get_user_public(username=None): # noqa: E501
"storage": user_workspace.storage if user_workspace else 104857600,
"storage_limit": user_workspace.storage if user_workspace else 104857600,
"organisations": {
ws.name: ws.get_user_role(current_user) for ws in all_workspaces
ws.name: ws.get_user_role(current_user).value for ws in all_workspaces
},
}
return user_info, 200
Expand Down Expand Up @@ -373,7 +372,7 @@ def register_user(): # pylint: disable=W0613,W0612

form = UserRegistrationForm()
if form.validate():
user = do_register_user(form.username.data, form.email.data, form.password.data)
user = User.create(form.username.data, form.email.data, form.password.data)
user_created.send(user, source="admin")
token = generate_confirmation_token(current_app, user.email)
confirm_url = f"confirm-email/{token}"
Expand Down Expand Up @@ -485,7 +484,7 @@ def get_user_info():
user_info = UserInfoSchema().dump(current_user)
workspaces = current_app.ws_handler.list_user_workspaces(current_user.username)
user_info["workspaces"] = [
{"id": ws.id, "name": ws.name, "role": ws.get_user_role(current_user)}
{"id": ws.id, "name": ws.name, "role": ws.get_user_role(current_user).value}
for ws in workspaces
]
preferred_workspace = current_app.ws_handler.get_preferred(current_user)
Expand All @@ -498,3 +497,45 @@ def get_user_info():
for inv in invitations
]
return user_info, 200


@auth_required
def create_user():
"""Create new user"""
workspace = current_app.ws_handler.get(request.json.get("workspace_id"))
if not (workspace and workspace.can_add_users(current_user)):
abort(403)

username = request.json.get(
"username", User.generate_username(request.json["email"])
)
form = UserRegistrationForm()
form.confirm.data = form.password.data
form.username.data = username
if not form.validate():
return jsonify(form.errors), 400

user = User.create(
form.username.data,
form.email.data,
form.password.data,
request.json.get("notify_user", False),
)
user_created.send(
user,
source="api",
workspace_id=request.json["workspace_id"],
workspace_role=request.json["role"],
)

if user.profile.receive_notifications:
send_confirmation_email(
current_app,
user,
"confirm-email",
"email/user_created.html",
"Invitation to Mergin Maps",
password=form.password.data,
)

return jsonify(UserInfoSchema().dump(user)), 201
47 changes: 46 additions & 1 deletion server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import List, Optional
import bcrypt
from flask import current_app, request
from sqlalchemy import or_, func
from sqlalchemy import or_, func, text

from ..app import db
from ..sync.models import ProjectUser
Expand Down Expand Up @@ -179,6 +179,51 @@ def anonymize(self):
self.profile.last_name = None
db.session.commit()

@classmethod
def get_by_login(cls, login: str) -> Optional[User]:
"""Find user by its login which can be either username or email"""
login = login.strip().lower()
return cls.query.filter(
or_(
func.lower(User.email) == login,
func.lower(User.username) == login,
)
).first()

@classmethod
def generate_username(cls, email: str) -> Optional[str]:
"""Autogenerate username from email"""
if not "@" in email:
return
username = email.split("@")[0].strip().lower()
# check if we already do not have existing usernames
suffix = db.session.execute(
text(
"""
SELECT
replace(username, :username, '0')::int AS suffix
FROM "user"
WHERE
username = :username OR
username SIMILAR TO :username'\d+'
ORDER BY replace(username, :username, '0')::int DESC
LIMIT 1;
"""
),
{"username": username},
).scalar()
return username if suffix is None else username + str(int(suffix) + 1)

@classmethod
def create(
cls, username: str, email: str, password: str, notifications: bool = True
) -> User:
user = cls(username.strip(), email.strip(), password, False)
user.profile = UserProfile(receive_notifications=notifications)
db.session.add(user)
db.session.commit()
return user


class UserProfile(db.Model):
user_id = db.Column(
Expand Down
2 changes: 1 addition & 1 deletion server/mergin/auth/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from sqlalchemy.sql.operators import isnot

from ..celery import celery
from .app import db
from ..app import db
from .models import User
from .config import Configuration

Expand Down
29 changes: 29 additions & 0 deletions server/mergin/sync/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

from abc import ABC, abstractmethod
from enum import Enum


class AbstractWorkspace:
Expand Down Expand Up @@ -74,6 +75,16 @@ def project_count(self):
"""Return number of workspace projects"""
pass

@abstractmethod
def members(self):
"""Return workspace members"""
pass

@abstractmethod
def can_add_users(self, user):
"""Check if user can add another user to workspace"""
pass


class WorkspaceHandler(ABC):
"""
Expand Down Expand Up @@ -169,3 +180,21 @@ def get_push_permission(self, changes: dict):
Return project permission for user to push data to project
"""
pass


class WorkspaceRole(Enum):
GUEST = "guest"
READER = "reader"
EDITOR = "editor"
WRITER = "writer"
ADMIN = "admin"
OWNER = "owner"

@classmethod
def values(cls):
return [member.value for member in cls.__members__.values()]

def __ge__(self, other):
"""Compare roles"""
members = list(WorkspaceRole.__members__)
return members.index(self.name) >= members.index(other.name)
22 changes: 22 additions & 0 deletions server/mergin/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ChangesSchema,
ProjectFile,
)
from .interfaces import WorkspaceRole
from .storages.disk import move_to_tmp
from ..app import db
from .storages import DiskStorage
Expand Down Expand Up @@ -281,6 +282,18 @@ def unset_role(self, user_id: int) -> None:
if member:
self.project_users.remove(member)

def get_member(self, user_id: int) -> Optional[ProjectMember]:
"""Get project member"""
member = self._member(user_id)
if member:
return ProjectMember(
id=user_id,
username=member.user.username,
email=member.user.email,
project_role=ProjectRole(member.role),
workspace_role=self.workspace.get_user_role(member.user),
)

def members_by_role(self, role: ProjectRole) -> List[int]:
"""Project members' ids with at least required role (or higher)"""
return [u.user_id for u in self.project_users if ProjectRole(u.role) >= role]
Expand Down Expand Up @@ -331,6 +344,15 @@ def __lt__(self, other):
return members.index(self.name) < members.index(other.name)


@dataclass
class ProjectMember:
id: int
email: str
username: str
workspace_role: WorkspaceRole
project_role: Optional[ProjectRole]


@dataclass
class ProjectAccessDetail:
id: int or str
Expand Down
2 changes: 1 addition & 1 deletion server/mergin/sync/private_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,10 @@ def accept_project_access_request(request_id):
project = access_request.project
project_role = ProjectPermissions.get_user_project_role(project, current_user)
if project_role == ProjectRole.OWNER:
access_request.accept(permission)
project_access_granted.send(
access_request.project, user_id=access_request.requested_by
)
access_request.accept(permission)
return "", 200
abort(403, "You don't have permissions to accept project access request")

Expand Down
5 changes: 3 additions & 2 deletions server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

from sqlalchemy.orm import load_only
from werkzeug.exceptions import HTTPException

from .interfaces import WorkspaceRole
from ..app import db
from ..auth import auth_required
from ..auth.models import User
Expand Down Expand Up @@ -220,7 +222,6 @@ def add_project(namespace): # noqa: E501
**request.json,
creator=current_user,
workspace=workspace,
public=request.json.get("public", False),
)
p.updated = datetime.utcnow()

Expand Down Expand Up @@ -1427,7 +1428,7 @@ def get_workspace_by_id(id):

if not (
ws.user_has_permissions(current_user, "read")
or ws.get_user_role(current_user) == "guest"
or ws.get_user_role(current_user) == WorkspaceRole.GUEST
):
abort(403, f"You do not have permissions to workspace")

Expand Down
Loading
Loading