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

Develop roles refactor #341

Merged
merged 22 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b89a313
Roles refactoring: Add new project_member table, update getter/setter…
varmar05 Oct 31, 2024
3bdd3dd
Merge remote-tracking branch 'origin/develop-roles-refactor' into rol…
MarcelGeo Nov 5, 2024
5bdeef7
Fix migration script to work with all deployments
varmar05 Nov 11, 2024
8277055
Fix migration script
varmar05 Nov 13, 2024
d7d7640
Fix downgrade in migration script
varmar05 Nov 14, 2024
3e4ab10
Roles refactor: Fix API responses with new internal db models
varmar05 Nov 15, 2024
e09239e
Merge pull request #319 from MerginMaps/roles-refactor-db-migration
MarcelGeo Nov 18, 2024
106da88
Roles refactor: Fix depracated update project access endpoint
varmar05 Nov 18, 2024
3c8595b
Merge branch 'develop' into develop-roles-refactor
varmar05 Nov 19, 2024
fc8e11d
Merge branch 'develop-roles-refactor' into fix_current_access_api
varmar05 Nov 19, 2024
75deca7
Address comments
varmar05 Nov 20, 2024
dcc7b86
More fixes for project access rework
varmar05 Nov 20, 2024
0b00f89
Rewrite pagination query to use subquery instead of join
varmar05 Nov 21, 2024
ead9554
Merge pull request #332 from MerginMaps/fix_current_access_api
MarcelGeo Nov 25, 2024
4d34f5d
Roles refactor: New public API with reworked project roles
varmar05 Nov 29, 2024
1b66b14
Create project fix
harminius Dec 6, 2024
4578903
Be safe to have mandatory public attribute
harminius Dec 6, 2024
be604ea
Add test for public argument
harminius Dec 9, 2024
56cd008
Address review comments
varmar05 Dec 9, 2024
dfa0d46
Rename functions to match API naming
varmar05 Dec 9, 2024
1fbdb12
Merge pull request #337 from MerginMaps/new_members_api
MarcelGeo Dec 10, 2024
8aecc98
Merge branch 'develop' into develop-roles-refactor
varmar05 Dec 11, 2024
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 @@ -180,7 +180,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,
)
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 @@ -642,6 +644,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: [email protected]
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):
"""
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 @@ -92,7 +91,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 @@ -374,7 +373,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 @@ -486,7 +485,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 @@ -501,6 +500,48 @@ def get_user_info():
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


@auth_required(permissions=["admin"])
def get_server_usage():
data = {
Expand Down
68 changes: 50 additions & 18 deletions server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
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
from ..sync.utils import get_user_agent, get_ip, get_device_id


Expand Down Expand Up @@ -149,24 +150,10 @@ def inactivate(self) -> None:
"""Inactivate user account and remove explicitly shared projects as well clean references to created projects.
User is then safe to be removed.
"""
from ..sync.models import Project, ProjectAccess, AccessRequest, RequestStatus
from ..sync.models import AccessRequest, RequestStatus

shared_projects = Project.query.filter(
or_(
Project.access.has(ProjectAccess.owners.contains([self.id])),
Project.access.has(ProjectAccess.writers.contains([self.id])),
Project.access.has(ProjectAccess.editors.contains([self.id])),
Project.access.has(ProjectAccess.readers.contains([self.id])),
)
).all()

for p in shared_projects:
for key in ("owners", "writers", "editors", "readers"):
value = set(getattr(p.access, key))
if self.id in value:
value.remove(self.id)
setattr(p.access, key, list(value))
db.session.add(p)
# remove explicit permissions
ProjectUser.query.filter(ProjectUser.user_id == self.id).delete()

# decline all access requests
for req in (
Expand All @@ -192,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
11 changes: 5 additions & 6 deletions server/mergin/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,16 @@ def get_disk_usage(self, obj):

def _has_project(self, obj):
# DEPRECATED functionality - kept for the backward-compatibility
from ..sync.models import Project, ProjectAccess
from ..sync.models import ProjectUser, Project

ws = current_app.ws_handler.get_by_name(obj.user.username)
if ws:
projects_count = (
Project.query.filter(Project.creator_id == obj.user.id)
Project.query.join(ProjectUser)
.filter(Project.creator_id == obj.user.id)
.filter(Project.removed_at.is_(None))
.filter_by(workspace_id=ws.id)
.filter(
Project.access.has(ProjectAccess.readers.contains([obj.user.id]))
)
.filter(Project.workspace_id == ws.id)
.filter(ProjectUser.user_id == obj.user.id)
.count()
)
return projects_count > 0
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
3 changes: 1 addition & 2 deletions server/mergin/sync/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from .files import UploadChanges
from ..app import db
from .models import Project, ProjectAccess, ProjectVersion
from .models import Project, ProjectVersion
from .utils import split_project_path
from ..auth.models import User

Expand Down Expand Up @@ -52,7 +52,6 @@ def create(name, namespace, username): # pylint: disable=W0612
p = Project(**project_params)
p.updated = datetime.utcnow()
db.session.add(p)
p.access = ProjectAccess(p, public=False)
changes = UploadChanges(added=[], updated=[], removed=[])
pv = ProjectVersion(p, 0, user.id, changes, "127.0.0.1")
pv.project = p
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)
Loading
Loading