Repository: hotosm/fmtm
Pull request #2353: Implement role wise access to project summaries
Author: login: Sujanadh
Labels: name: enhancement
description: New feature or request
name: backend
description: Related to backend code
Created: 2025-04-09T07:32:17Z | Merged: 2025-04-09T21:00:20Z

## What type of PR is this? (check all applicable)

- [X] 🍕 Feature
- [ ] 🐛 Bug Fix
- [ ] 📝 Documentation
- [ ] 🧑‍💻 Refactor
- [ ] ✅ Test
- [ ] 🤖 Build or CI
- [ ] ❓ Other (please specify)

## Related Issue

- Issue: #2311
- osm login issue in mapper page

## Describe this PR

- Create a `public_view` dependency to allow public users without login to view the project summaries. The main goal of introducing it is to get the user information if they are logged in so that we can check if the user has access to the private projects or not. If `login_required` is used here user won't be able to view project cards without logging in.

- Check if the user is `superadmin`; if he is `superadmin`, he can view every project.

- Check if the user is `org_admin`; then he can view all the projects within the organisation, whether the projects are private or public, along with the other public projects from other organisations.

- Check if the user is a mapper and he has access to private projects; otherwise, he will only view public projects.

- Update osm callback endpoint in mapper page `callback/osm -> callback/osm/mapper`
## TODO

- Implement other project visibility categories such as sensitive, invite only.
- Implement access likewise, to view, to contribute and so on.

## Screenshots

N/A

## Alternative Approaches Considered

Did you attempt any other approaches that are not documented in code?

## Review Guide

Notes for the reviewer. How to test this change?

## Checklist before requesting a review

- 📖 Read the FMTM Contributing Guide: <https://github.com/hotosm/fmtm/blob/main/CONTRIBUTING.md>
- 📖 Read the HOT Code of Conduct: <https://docs.hotosm.org/code-of-conduct>
- 👷‍♀️ Create small PRs. In most cases, this will be possible.
- ✅ Provide tests for your changes.
- 📝 Use descriptive commit messages.
- 📗 Update any related documentation and include any relevant screenshots.

## [optional] What gif best describes this PR or how it makes you feel?



Diff:
diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py
index 5ba4a8ea4f..1a148d846b 100644
--- a/src/backend/app/auth/auth_deps.py
+++ b/src/backend/app/auth/auth_deps.py
@@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
+    request: Request,
+    access_token: Annotated[Optional[str], Header()] = None,
+) -> Optional[AuthUser]:
+    """Optional login dependency: returns AuthUser if authenticated, else None."""
+    if settings.DEBUG:
+        return AuthUser(sub="osm|1", username="localadmin", role=UserRole.ADMIN)
+
+    extracted_token = access_token or get_cookie_value(request, settings.cookie_name)
+
+    if not extracted_token:
+        return None
+
+    try:
+        return await _authenticate_user(extracted_token)
+    except Exception:
+        return None  # Fail silently to allow access to public content
 
 
 async def login_required(
diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py
index 2613dc6ba3..06b5959217 100644
--- a/src/backend/app/db/models.py
+++ b/src/backend/app/db/models.py
@@ -1481,6 +1481,7 @@ async def all(
         db: Connection,
         skip: Optional[int] = None,
         limit: Optional[int] = None,
+        current_user: Optional[str] = None,
         org_id: Optional[int] = None,
         user_sub: Optional[str] = None,
         hashtags: Optional[list[str]] = None,
@@ -1488,6 +1489,55 @@ async def all(
         minimal: bool = False,
     ) -> Optional[list[Self]]:
         """Fetch all projects with optional filters for user, hashtags, and search."""
+        access_info = await cls._get_user_access_level(db, current_user)
+        filters, params, needs_user_roles_join = cls._build_query_filters(
+            skip, limit, org_id, user_sub, hashtags, search, access_info
+        )
+        sql = cls._construct_sql_query(
+            filters, minimal, skip, limit, needs_user_roles_join
+        )
+
+        # Execute query and return results
+        async with db.cursor(row_factory=class_row(cls)) as cur:
+            await cur.execute(sql, params)
+            return await cur.fetchall()
+
+    @classmethod
+    async def _get_user_access_level(
+        cls, db: Connection, current_user: Optional[str]
+    ) -> dict:
+        """Extract user context information for authorization checks."""
+        access_info = {
+            "is_authenticated": current_user is not None,
+            "is_superadmin": False,
+            "managed_org_ids": [],
+            "user_sub": None,
+        }
+
+        if current_user:
+            db_user = await DbUser.one(db, current_user.sub)
+            access_info["is_superadmin"] = db_user.role == UserRole.ADMIN
+            managed_orgs = (
+                db_user.orgs_managed if hasattr(db_user, "orgs_managed") else []
+            )
+            access_info["managed_org_ids"] = managed_orgs
+            access_info["user_sub"] = current_user.sub
+
+        return access_info
+
+    @classmethod
+    def _build_query_filters(
+        cls,
+        skip: Optional[int],
+        limit: Optional[int],
+        org_id: Optional[int],
+        user_sub: Optional[str],
+        hashtags: Optional[list[str]],
+        search: Optional[str],
+        access_info: dict,
+    ) -> tuple[list[str], dict, bool]:
+        """Build query filters and parameters based on provided criteria."""
+        # Build basic filters
         filters_map = {
             "p.organisation_id = %(org_id)s": org_id,
             "p.author_sub = %(user_sub)s": user_sub,  # project author
@@ -1496,7 +1546,17 @@ async def all(
             "p.slug ILIKE %(search)s": f"%{search}%" if search else None,
         }
 
-        filters = [condition for condition, value in filters_map.items() if value]
+        filters = [
+            condition for condition, value in filters_map.items() if value is not None
+        ]
+
+        # Add visibility filter based on user authorization
+        visibility_filter = cls._build_visibility_filter(access_info)
+        needs_user_roles_join = False
+        if visibility_filter:
+            needs_user_roles_join = True
+            filters.append(visibility_filter)
+
         params = {
             key: value
             for key, value in {
@@ -1509,60 +1569,128 @@ async def all(
             }.items()
             if value
         }
-        if minimal:
-            # Minimal query for mapper frontend
-            sql = f"""
-                SELECT
-                    p.id,
-                    p.name,
-                    p.location_str,
-                    p.short_description,
-                    project_org.logo AS organisation_logo
-                FROM
-                    projects p
-                LEFT JOIN
-                    organisations project_org ON p.organisation_id = project_org.id
-                {"WHERE " + " AND ".join(filters) if filters else ""}
-                GROUP BY
-                    p.id, project_org.id
-                ORDER BY
-                    p.created_at DESC
+
+        if access_info["is_authenticated"]:
+            params["current_user_sub"] = access_info["user_sub"]
+            if access_info["managed_org_ids"]:
+                params["managed_org_ids"] = access_info["managed_org_ids"]
+
+        return filters, params, needs_user_roles_join
+
+    @classmethod
+    def _build_visibility_filter(cls, access_info: dict) -> Optional[str]:
+        """Build visibility filter based on user context."""
+        if not access_info["is_authenticated"]:
+            return "p.visibility = 'PUBLIC'"
+
+        if access_info["is_superadmin"]:
+            # Superadmin sees everything
+            return None
+
+        if access_info["managed_org_ids"]:
+            # Org managers see all projects in their orgs
+            return """
+                (
+                    p.visibility = 'PUBLIC'
+                    OR p.organisation_id = ANY(%(managed_org_ids)s)
+                )
             """
+
+        # Regular users see public projects and private ones they have access to
+        return """
+            (
+                p.visibility = 'PUBLIC'
+                OR (
+                    p.visibility = 'PRIVATE'
+                    AND ur.user_sub = %(current_user_sub)s
+                )
+            )
+        """
+
+    @classmethod
+    def _construct_sql_query(
+        cls,
+        filters: list,
+        minimal: bool,
+        skip: Optional[int],
+        limit: Optional[int],
+        needs_user_roles_join: Optional[bool] = False,
+    ) -> str:
+        """Construct SQL query based on filters and query type."""
+        where_clause = f"WHERE {' AND '.join(filters)}" if filters else ""
+
+        # Only join user_roles if needed for private visibility check
+        if needs_user_roles_join:
+            user_roles_join = "LEFT JOIN user_roles ur ON ur.project_id = p.id"
         else:
-            sql = f"""
-                SELECT
-                    p.*,
-                    ST_AsGeoJSON(p.outline)::jsonb AS outline,
-                    ST_AsGeoJSON(ST_Centroid(p.outline))::jsonb AS centroid,
-                    project_org.logo AS organisation_logo,
-                    p.total_tasks,
-                    stats.num_contributors,
-                    stats.total_submissions,
-                    stats.tasks_mapped,
-                    stats.tasks_bad,
-                    stats.tasks_validated
-                FROM
-                    projects p
-                LEFT JOIN
-                    organisations project_org ON p.organisation_id = project_org.id
-                LEFT JOIN
-                    mv_project_stats stats ON p.id = stats.project_id
-                {"WHERE " + " AND ".join(filters) if filters else ""}
-                ORDER BY
-                    p.created_at DESC
-            """
-        sql += (
+            user_roles_join = ""
+
+        sql = f"""
+            WITH filtered_projects AS (
+                SELECT p.* FROM projects p
+                {user_roles_join}
+                {where_clause}
+            )
+        """
+
+        # Add appropriate SELECT based on minimal flag
+        if minimal:
+            sql += cls._minimal_select_query()
+        else:
+            sql += cls._full_select_query()
+
+        if skip is not None and limit is not None:
+            sql += """
+                OFFSET %(offset)s
+                LIMIT %(limit)s;
             """
-            OFFSET %(offset)s
-            LIMIT %(limit)s;
+        else:
+            sql += ";"
+
+        return sql
+
+    @classmethod
+    def _minimal_select_query(cls) -> str:
+        """Return minimal SELECT query for mapper frontend."""
+        return """
+            SELECT
+                fp.id,
+                fp.name,
+                fp.location_str,
+                fp.short_description,
+                project_org.logo AS organisation_logo
+            FROM
+                filtered_projects fp
+            LEFT JOIN
+                organisations project_org ON fp.organisation_id = project_org.id
+            ORDER BY
+                fp.created_at DESC
         """
-            if skip and limit
-            else ";"
-        )
 
-        async with db.cursor(row_factory=class_row(cls)) as cur:
-            await cur.execute(sql, params)
-            return await cur.fetchall()
+    @classmethod
+    def _full_select_query(cls) -> str:
+        """Return full SELECT query with all project details."""
+        return """
+            SELECT
+                fp.*,
+                ST_AsGeoJSON(fp.outline)::jsonb AS outline,
+                ST_AsGeoJSON(ST_Centroid(fp.outline))::jsonb AS centroid,
+                project_org.logo AS organisation_logo,
+                fp.total_tasks,
+                stats.num_contributors,
+                stats.total_submissions,
+                stats.tasks_mapped,
+                stats.tasks_bad,
+                stats.tasks_validated
+            FROM
+                filtered_projects fp
+            LEFT JOIN
+                organisations project_org ON fp.organisation_id = project_org.id
+            LEFT JOIN
+                mv_project_stats stats ON fp.id = stats.project_id
+            ORDER BY
+                fp.created_at DESC
+        """
 
     @classmethod
     async def create(cls, db: Connection, project_in: "ProjectIn") -> Self:
diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py
index 183714b1e8..584846187c 100644
--- a/src/backend/app/projects/project_crud.py
+++ b/src/backend/app/projects/project_crud.py
@@ -891,6 +891,7 @@ async def get_paginated_projects(
     db: Connection,
     page: int,
     results_per_page: int,
+    current_user: Optional[str] = None,
     org_id: Optional[int] = None,
     user_sub: Optional[str] = None,
     hashtags: Optional[str] = None,
@@ -904,6 +905,7 @@ async def get_paginated_projects(
     # Get subset of projects
     projects = await DbProject.all(
         db,
+        current_user=current_user,
         org_id=org_id,
         user_sub=user_sub,
         hashtags=hashtags,
diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py
index cbda9cc47d..4a0f023451 100644
--- a/src/backend/app/projects/project_routes.py
+++ b/src/backend/app/projects/project_routes.py
@@ -46,7 +46,7 @@
 from psycopg import Connection
 from psycopg.rows import dict_row
 
-from app.auth.auth_deps import login_required, mapper_login_required
+from app.auth.auth_deps import login_required, mapper_login_required, public_view
 from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict
 from app.auth.providers.osm import check_osm_user, init_osm_auth
 from app.auth.roles import check_access, mapper, org_admin, project_manager
@@ -148,6 +148,7 @@ async def read_project_summaries(
     db: Annotated[Connection, Depends(db_conn)],
     page: int = Query(1, ge=1),  # Default to page 1, must be greater than or equal to 1
     results_per_page: int = Query(13, le=100),
+    current_user: Annotated[Optional[AuthUser], Depends(public_view)] = None,
     org_id: Optional[int] = None,
     user_sub: Optional[str] = None,
     hashtags: Optional[str] = None,
@@ -159,7 +160,15 @@ async def read_project_summaries(
     NOTE this is a public endpoint with no auth requirements.
     """
     return await project_crud.get_paginated_projects(
-        db, page, results_per_page, org_id, user_sub, hashtags, search, minimal
+        db,
+        page,
+        results_per_page,
+        current_user,
+        org_id,
+        user_sub,
+        hashtags,
+        search,
+        minimal,
     )
 
 
diff --git a/src/mapper/src/lib/components/auth.svelte b/src/mapper/src/lib/components/auth.svelte
index 91d6375bca..555bb650c4 100644
--- a/src/mapper/src/lib/components/auth.svelte
+++ b/src/mapper/src/lib/components/auth.svelte
@@ -21,7 +21,7 @@
 						{ credentials: 'include' },
 					);
 				} else {
-					response = await fetch(`${import.meta.env.VITE_API_URL}/auth/callback/osm?code=${authCode}&state=${state}`, {
+					response = await fetch(`${import.meta.env.VITE_API_URL}/auth/callback/osm/mapper?code=${authCode}&state=${state}`, {
 						credentials: 'include',
 					});
 				}


Reviews:
user: login: spwoodcock
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-09T09:30:02Z
user: login: Sujanadh
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T10:10:57Z
user: login: spwoodcock
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T10:37:55Z
user: login: spwoodcock
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T10:41:15Z
user: login: spwoodcock
body: This is a really nice change - apologies for the rushed review - you documented the change really well in the description😅 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T10:45:11Z
user: login: Sujanadh
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T10:47:25Z
user: login: spwoodcock
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T11:01:18Z
user: login: spwoodcock
body: Happy to merge this as it is. My only comment would be, as the `.all` method has grown so huge, we could probably make functions to make it easier to understand, e.g.

```python
def build_visibility_filter(is_superadmin, managed_org_ids, current_user_sub):
    if is_superadmin:
        return None, {}
    elif managed_org_ids:
        return (
            "(p.visibility = 'PUBLIC' OR p.organisation_id = ANY(%(managed_org_ids)s))",
            {"managed_org_ids": managed_org_ids}
        )
    else:
        return (
            "(p.visibility = 'PUBLIC' OR (p.visibility = 'PRIVATE' AND ur.user_sub = %(current_user_sub)s))",
            {"current_user_sub": current_user_sub}
        )

# then
visibility_filter, visibility_params = build_visibility_filter(...)
if visibility_filter:
    filters.append(visibility_filter)
params.update(visibility_params)
```

we could do this for adding the search filters etc too
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T11:01:30Z
user: login: spwoodcock
body: This is really nice - great work 👏 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T20:59:53Z

Review comments:
diff_hunk: @@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
path: src/backend/app/auth/auth_deps.py
user: login: spwoodcock
body: I like this idea, but do we definitely need this extra access level?

If we use `login_required` then the endpoint is truly public and we don't care about auth.

If we use `mapper` role, shouldn't we implement the logic to check for public/private here?

Maybe I haven't thought this through properly
created_at: 2025-04-09T09:29:38Z
author_association: MEMBER
diff_hunk: @@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
path: src/backend/app/auth/auth_deps.py
user: login: Sujanadh
body: So implementing login required won't allow users to view the projects without logging in to the FMTM; we need tokens set, the same for the mapper role. So, I would say `login_required` is only public if they have signed up in FMTM.

created_at: 2025-04-09T10:10:57Z
author_association: COLLABORATOR
diff_hunk: @@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
path: src/backend/app/auth/auth_deps.py
user: login: spwoodcock
body: Ah of course, my bad there - we do need the new dependency as you have created 👍 
- `login_required` is still needed to add authentication to endpoints that have no associated project, e.g. the helper endpoints. These endpoints are entirely public, with no checking.
- I would propose renaming `public_view` to `project_visibility`. The code would read like `Depends(project_visibility)`, which I think is quite intuitive.
created_at: 2025-04-09T10:37:54Z
author_association: MEMBER
diff_hunk: @@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
path: src/backend/app/auth/auth_deps.py
user: login: spwoodcock
body: Ah sorry again, I actually looked through your code more thoroughly. I understand what you are trying to do.

We need a dependency to get the currently user, but also fallback to allowing no login!
created_at: 2025-04-09T10:41:15Z
author_association: MEMBER
diff_hunk: @@ -268,6 +268,23 @@ async def expire_cookies(response: Response, cookie_names: list[str]) -> Respons
 
 
 ### Endpoint Dependencies ###
+async def public_view(
path: src/backend/app/auth/auth_deps.py
user: login: Sujanadh
body: So you suggest naming the dependency differently, or is it good to go here?
created_at: 2025-04-09T10:47:25Z
author_association: COLLABORATOR
diff_hunk: @@ -1509,47 +1510,95 @@ async def all(
             }.items()
             if value
         }
+        is_superadmin = False
+        managed_org_ids = []
+
+        if current_user:
+            db_user = await DbUser.one(db, current_user.sub)
+            is_superadmin = db_user.role == UserRole.ADMIN
+            managed_org_ids = (
+                db_user.orgs_managed if hasattr(db_user, "orgs_managed") else []
+            )
+            params["current_user_sub"] = current_user.sub
+
+        if current_user:
path: src/backend/app/db/models.py
user: login: spwoodcock
body: No need for second `if current_user` here
created_at: 2025-04-09T11:01:17Z
author_association: MEMBER

PR comments:
user: login: Sujanadh
created_at: 2025-04-09T11:06:05Z
body: > Happy to merge this as it is. My only comment would be, as the `.all` method has grown so huge, we could probably make functions to make it easier to understand, e.g.
> 
> ```python
> def build_visibility_filter(is_superadmin, managed_org_ids, current_user_sub):
>     if is_superadmin:
>         return None, {}
>     elif managed_org_ids:
>         return (
>             "(p.visibility = 'PUBLIC' OR p.organisation_id = ANY(%(managed_org_ids)s))",
>             {"managed_org_ids": managed_org_ids}
>         )
>     else:
>         return (
>             "(p.visibility = 'PUBLIC' OR (p.visibility = 'PRIVATE' AND ur.user_sub = %(current_user_sub)s))",
>             {"current_user_sub": current_user_sub}
>         )
> 
> # then
> visibility_filter, visibility_params = build_visibility_filter(...)
> if visibility_filter:
>     filters.append(visibility_filter)
> params.update(visibility_params)
> ```
> 
> we could do this for adding the search filters etc too

That's what I wanted to do :+1: , I will probably make these changes as you have suggested.

author_association: COLLABORATOR
user: login: Sujanadh
created_at: 2025-04-09T12:18:59Z
body: ## Updates:
- Added a `needs_user_roles_join` parameter to control when the `user_roles` join should be included
- Created separate function to get the user access level `_get_user_access_level`
- Refactored the visibility filter logic into separate function `_build_visibility_filter`
- Separated minimal and full query to separate functions :-  `_minimal_select_query` , `_full_select_query`
author_association: COLLABORATOR