Skip to content

Commit bed303c

Browse files
authored
Merge pull request #238 from MerginMaps/develop
2024.4.0
2 parents 3ecee96 + 5e08534 commit bed303c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+589
-210
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ gen
2121
# pyenv
2222
.python-version
2323
web-app/.node-version
24+
25+
# datadir for database
26+
mergin-db-ce

server/mergin/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def create_app(public_keys: List[str] = None) -> Flask:
143143
from .sync.config import Configuration as SyncConfig
144144
from .sync.commands import add_commands
145145
from .auth import register as register_auth
146+
from .sync.project_handler import ProjectHandler
146147

147148
app = create_simple_app().connexion_app
148149

@@ -397,6 +398,7 @@ def log_bad_request(response):
397398

398399
# we need to register default handler to be accessible within app
399400
application.ws_handler = GlobalWorkspaceHandler()
401+
application.project_handler = ProjectHandler()
400402

401403
# append config route with settings from app.config needed by clients
402404
if public_keys:

server/mergin/auth/api.yaml

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -525,13 +525,7 @@ paths:
525525
type: object
526526
nullable: true
527527
additionalProperties:
528-
type: string
529-
enum:
530-
- owner
531-
- admin
532-
- writer
533-
- reader
534-
- guest
528+
$ref: "#/components/schemas/WorkspaceRole"
535529
example: { myWorkspace: owner }
536530
"400":
537531
$ref: "#/components/responses/BadStatusResp"
@@ -805,8 +799,7 @@ components:
805799
type: string
806800
example: my-workspace
807801
role:
808-
type: string
809-
example: reader
802+
$ref: "#/components/schemas/WorkspaceRole"
810803
preferred_workspace:
811804
type: integer
812805
nullable: true
@@ -824,8 +817,7 @@ components:
824817
type: string
825818
example: my-workspace
826819
role:
827-
type: string
828-
example: reader
820+
$ref: "#/components/schemas/WorkspaceRole"
829821
LoginResponse:
830822
allOf:
831823
- $ref: "#/components/schemas/UserDetail"
@@ -838,3 +830,13 @@ components:
838830
type: string
839831
format: date-time
840832
example: 2019-05-04T14:21:56.695035Z
833+
WorkspaceRole:
834+
type: string
835+
enum:
836+
- owner
837+
- admin
838+
- writer
839+
- editor
840+
- reader
841+
- guest
842+
example: reader

server/mergin/auth/app.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from flask import current_app, render_template
88
from flask_login import current_user
99
from itsdangerous import URLSafeTimedSerializer
10+
from sqlalchemy import func
1011

1112
from .commands import add_commands
1213
from .config import Configuration
@@ -72,10 +73,10 @@ def wrapped_func(*args, **kwargs):
7273

7374
def authenticate(login, password):
7475
if "@" in login:
75-
query = {"email": login}
76+
query = func.lower(User.email) == func.lower(login)
7677
else:
77-
query = {"username": login}
78-
user = User.query.filter_by(**query).one_or_none()
78+
query = func.lower(User.username) == func.lower(login)
79+
user = User.query.filter(query).one_or_none()
7980
if user and user.check_password(password):
8081
return user
8182

server/mergin/auth/commands.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import click
66
from flask import Flask
7-
from sqlalchemy import or_
7+
from sqlalchemy import or_, func
88

99
from .. import db
1010
from .models import User, UserProfile
@@ -24,7 +24,10 @@ def user():
2424
def create(username, password, is_admin, email): # pylint: disable=W0612
2525
"""Create user account"""
2626
user = User.query.filter(
27-
or_(User.username == username, User.email == email)
27+
or_(
28+
func.lower(User.username) == func.lower(username),
29+
func.lower(User.email) == func.lower(email),
30+
)
2831
).first()
2932
if user:
3033
print("ERROR: User already exists!\n")

server/mergin/auth/models.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import List, Optional
88
import bcrypt
99
from flask import current_app, request
10-
from sqlalchemy import or_
10+
from sqlalchemy import or_, func
1111

1212
from .. import db
1313
from ..sync.utils import get_user_agent, get_ip, get_device_id
@@ -16,8 +16,8 @@
1616
class User(db.Model):
1717
id = db.Column(db.Integer, primary_key=True)
1818

19-
username = db.Column(db.String(80), unique=True, info={"label": "Username"})
20-
email = db.Column(db.String(120), unique=True)
19+
username = db.Column(db.String(80), info={"label": "Username"})
20+
email = db.Column(db.String(120))
2121

2222
passwd = db.Column(db.String(80), info={"label": "Password"}) # salted + hashed
2323

@@ -32,6 +32,11 @@ class User(db.Model):
3232
default=datetime.datetime.utcnow,
3333
)
3434

35+
__table_args__ = (
36+
db.Index("ix_user_username", func.lower(username), unique=True),
37+
db.Index("ix_user_email", func.lower(email), unique=True),
38+
)
39+
3540
def __init__(self, username, email, passwd, is_admin=False):
3641
self.username = username
3742
self.email = email
@@ -150,6 +155,7 @@ def inactivate(self) -> None:
150155
or_(
151156
Project.access.has(ProjectAccess.owners.contains([self.id])),
152157
Project.access.has(ProjectAccess.writers.contains([self.id])),
158+
Project.access.has(ProjectAccess.editors.contains([self.id])),
153159
Project.access.has(ProjectAccess.readers.contains([self.id])),
154160
)
155161
).all()

server/mergin/sync/forms.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,10 @@ class AccessPermissionForm(FlaskForm):
1919
permissions = SelectField(
2020
"permissions",
2121
[DataRequired()],
22-
choices=[("read", "read"), ("write", "write"), ("owner", "owner")],
22+
choices=[
23+
("read", "read"),
24+
("edit", "edit"),
25+
("write", "write"),
26+
("owner", "owner"),
27+
],
2328
)

server/mergin/sync/interfaces.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ def disk_usage(self):
5050
pass
5151

5252
def user_has_permissions(self, user, permissions):
53-
"""Check whether User obj has read/write/admin permissions to workspace
53+
"""Check whether User obj has read/write/admin permissions to workspace or project
5454
Current rules are:
5555
- read: user can list and download all projects within workspace
56+
- edit: user can add/update features on a map, can't remove any files, change any layers (schema) nor update *.qgz or *.qgs files
5657
- write: user can push to any projects within workspace
5758
- admin: user can create new projects, delete projects within workspace
5859
and modify read/write permissions for other users
@@ -152,3 +153,12 @@ def workspace_count():
152153
Return number of workspaces
153154
"""
154155
pass
156+
157+
158+
class AbstractProjectHandler(ABC):
159+
@abstractmethod
160+
def get_push_permission(self, changes: dict):
161+
"""
162+
Return project permission for user to push data to project
163+
"""
164+
pass

server/mergin/sync/models.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from datetime import datetime, timedelta
99
from enum import Enum
1010
from typing import Optional, List, Dict, Set
11+
from dataclasses import dataclass, asdict
1112

1213
from blinker import signal
1314
from flask_login import current_user
@@ -324,7 +325,9 @@ def delete(self, removed_by: int = None):
324325
db.session.execute(
325326
upload_table.delete().where(upload_table.c.project_id == self.id)
326327
)
327-
self.access.owners = self.access.writers = self.access.readers = []
328+
self.access.owners = self.access.writers = self.access.editors = (
329+
self.access.readers
330+
) = []
328331
access_requests = (
329332
AccessRequest.query.filter_by(project_id=self.id)
330333
.filter(AccessRequest.status.is_(None))
@@ -339,6 +342,7 @@ def delete(self, removed_by: int = None):
339342
class ProjectRole(Enum):
340343
OWNER = "owner"
341344
WRITER = "writer"
345+
EDITOR = "editor"
342346
READER = "reader"
343347

344348
def __gt__(self, other):
@@ -354,6 +358,17 @@ def __gt__(self, other):
354358
return False
355359

356360

361+
@dataclass
362+
class ProjectAccessDetail:
363+
id: int or str
364+
email: str
365+
role: str
366+
username: str
367+
name: Optional[str]
368+
project_permission: str
369+
type: str
370+
371+
357372
class ProjectAccess(db.Model):
358373
project_id = db.Column(
359374
UUID(as_uuid=True),
@@ -365,6 +380,7 @@ class ProjectAccess(db.Model):
365380
owners = db.Column(ARRAY(db.Integer), server_default="{}")
366381
readers = db.Column(ARRAY(db.Integer), server_default="{}")
367382
writers = db.Column(ARRAY(db.Integer), server_default="{}")
383+
editors = db.Column(ARRAY(db.Integer), server_default="{}")
368384

369385
project = db.relationship(
370386
"Project",
@@ -382,13 +398,15 @@ class ProjectAccess(db.Model):
382398
db.Index("ix_project_access_owners", owners, postgresql_using="gin"),
383399
db.Index("ix_project_access_readers", readers, postgresql_using="gin"),
384400
db.Index("ix_project_access_writers", writers, postgresql_using="gin"),
401+
db.Index("ix_project_access_editors", editors, postgresql_using="gin"),
385402
)
386403

387404
def __init__(self, project, public=False):
388405
self.project = project
389406
self.owners = [project.creator.id]
390407
self.writers = [project.creator.id]
391408
self.readers = [project.creator.id]
409+
self.editors = [project.creator.id]
392410
self.project_id = project.id
393411
self.public = public
394412

@@ -398,6 +416,8 @@ def get_role(self, user_id: int) -> Optional[ProjectRole]:
398416
return ProjectRole.OWNER
399417
elif user_id in self.writers:
400418
return ProjectRole.WRITER
419+
elif user_id in self.editors:
420+
return ProjectRole.EDITOR
401421
elif user_id in self.readers:
402422
return ProjectRole.READER
403423
else:
@@ -409,8 +429,9 @@ def _permission_attrs(role: ProjectRole) -> List[str]:
409429
# because roles do not inherit, they must be un/set explicitly in db ACLs
410430
perm_list = {
411431
ProjectRole.READER: ["readers"],
412-
ProjectRole.WRITER: ["writers", "readers"],
413-
ProjectRole.OWNER: ["owners", "writers", "readers"],
432+
ProjectRole.EDITOR: ["editors", "readers"],
433+
ProjectRole.WRITER: ["writers", "editors", "readers"],
434+
ProjectRole.OWNER: ["owners", "writers", "editors", "readers"],
414435
}
415436
return perm_list[role]
416437

@@ -440,7 +461,7 @@ def unset_role(self, user_id: int) -> None:
440461
def bulk_update(self, new_access: Dict) -> Set[int]:
441462
"""From new access lists do bulk update and return ids with any change applied"""
442463
diff = set()
443-
for key in ("owners", "writers", "readers"):
464+
for key in ("owners", "writers", "editors", "readers"):
444465
new_value = new_access.get(key, None)
445466
if not new_value:
446467
continue
@@ -450,7 +471,8 @@ def bulk_update(self, new_access: Dict) -> Set[int]:
450471

451472
# make sure lists are consistent (they inherit from each other)
452473
self.writers = list(set(self.writers).union(set(self.owners)))
453-
self.readers = list(set(self.readers).union(set(self.writers)))
474+
self.editors = list(set(self.editors).union(set(self.writers)))
475+
self.readers = list(set(self.readers).union(set(self.editors)))
454476
return diff
455477

456478

@@ -638,18 +660,16 @@ def expire(self):
638660
def accept(self, permissions):
639661
"""Accept project access request"""
640662
project_access = self.project.access
641-
readers = project_access.readers.copy()
642-
writers = project_access.writers.copy()
643-
owners = project_access.owners.copy()
644-
readers.append(self.requested_by)
645-
project_access.readers = readers
646-
if permissions == "write" or permissions == "owner":
647-
writers.append(self.requested_by)
648-
project_access.writers = writers
649-
if permissions == "owner":
650-
owners.append(self.requested_by)
651-
project_access.owners = owners
663+
PERMISSION_PROJECT_ROLE = {
664+
"read": ProjectRole.READER,
665+
"edit": ProjectRole.EDITOR,
666+
"write": ProjectRole.WRITER,
667+
"owner": ProjectRole.OWNER,
668+
}
652669

670+
project_access.set_role(
671+
self.requested_by, PERMISSION_PROJECT_ROLE.get(permissions)
672+
)
653673
self.resolve(RequestStatus.ACCEPTED, current_user.id)
654674
db.session.commit()
655675

0 commit comments

Comments
 (0)