diff --git a/compendium_v2/db/auth_model.py b/compendium_v2/db/auth_model.py
index b105a8b1947c754fea227cd70a922d4100bf81e0..50be7e3e009ef40c6762738a72b9049cf8923496 100644
--- a/compendium_v2/db/auth_model.py
+++ b/compendium_v2/db/auth_model.py
@@ -75,6 +75,10 @@ class User(UserMixin, db.Model):
     def is_admin(self):
         return self.roles == ROLES.admin
 
+    @property
+    def is_observer(self):
+        return self.roles == ROLES.observer
+
     @property
     def nren(self):
         if len(self.nrens) == 0:
diff --git a/compendium_v2/routes/response.py b/compendium_v2/routes/response.py
index 60b7fa9b6bde972d6006e65dec4b58dc3758bc42..56d99fea6a373fa2a279b942274b9240d879a1a8 100644
--- a/compendium_v2/routes/response.py
+++ b/compendium_v2/routes/response.py
@@ -77,16 +77,29 @@ class SurveyMode(str, Enum):
     Edit = "edit"
 
 
-def check_access_nren(user: User, nren: str) -> bool:
+def check_access_nren_read(user: User, nren: str) -> bool:
     if user.is_anonymous:
         return False
     if user.is_admin:
         return True
+    if user.is_observer:
+        return True
     if nren == user.nren:
         return True
     return False
 
 
+def check_access_nren_write(user: User, nren: str) -> bool:
+    if not check_access_nren_read(current_user, nren):
+        return False
+    if user.is_observer:
+        # observers can't edit their own nrens either!
+        return False
+    # admins can edit all nrens
+    # users can edit their own nrens
+    return True
+
+
 @routes.route('/try/<int:year>', methods=['GET'])
 @common.require_accepts_json
 @admin_required
@@ -206,7 +219,7 @@ def load_survey(year, nren_name) -> Any:
     if not survey:
         return {'message': 'Survey not found'}, 404
 
-    if not check_access_nren(current_user, nren):
+    if not check_access_nren_read(current_user, nren):
         return {'message': 'You do not have permissions to access this survey.'}, 403
 
     response = db.session.scalar(
@@ -215,6 +228,8 @@ def load_survey(year, nren_name) -> Any:
 
     data, page, verification_status, locked_by = get_response_data(response, year, nren.id)
 
+    edit_allowed = current_user.is_admin or (
+        survey.status == SurveyStatus.open and check_access_nren_write(current_user, nren))
     return {
         "model": survey.survey,
         "locked_by": locked_by,
@@ -223,7 +238,7 @@ def load_survey(year, nren_name) -> Any:
         "verification_status": verification_status,
         "mode": SurveyMode.Display,
         "status": response.status.value if response else RESPONSE_NOT_STARTED,
-        "edit_allowed": current_user.is_admin or survey.status == SurveyStatus.open
+        "edit_allowed": edit_allowed
     }
 
 
@@ -249,7 +264,7 @@ def lock_survey(year, nren_name) -> Any:
     if not survey:
         return {'message': 'Survey not found'}, 404
 
-    if not check_access_nren(current_user, nren):
+    if not check_access_nren_write(current_user, nren):
         return {'message': 'You do not have permissions to access this survey.'}, 403
 
     if survey.status != SurveyStatus.open and not current_user.is_admin:
@@ -321,7 +336,7 @@ def save_survey(year, nren_name) -> Any:
     if survey is None:
         return {'message': 'Survey not found'}, 404
 
-    if not check_access_nren(current_user, nren):
+    if not check_access_nren_write(current_user, nren):
         return {'message': 'You do not have permission to edit this survey.'}, 403
 
     if survey.status != SurveyStatus.open and not current_user.is_admin:
@@ -384,7 +399,7 @@ def unlock_survey(year, nren_name) -> Any:
     if survey is None:
         return {'message': 'Survey not found'}, 404
 
-    if not check_access_nren(current_user, nren):
+    if not check_access_nren_write(current_user, nren):
         return {'message': 'You do not have permission to edit this survey.'}, 403
 
     response = db.session.scalar(
diff --git a/compendium_v2/routes/survey.py b/compendium_v2/routes/survey.py
index 041e60f9c91ffc992da03a3e5e3bf3ce2ffffa60..0a0717673c5f554002f33719c896762c1b7ff4d8 100644
--- a/compendium_v2/routes/survey.py
+++ b/compendium_v2/routes/survey.py
@@ -2,6 +2,7 @@ import logging
 from typing import Any, TypedDict, List, Dict
 
 from flask import Blueprint
+from flask_login import login_required, current_user
 from sqlalchemy import delete, select
 from sqlalchemy.orm import joinedload, load_only
 
@@ -47,7 +48,7 @@ LIST_SURVEYS_RESPONSE_SCHEMA = {
 
 @routes.route('/list', methods=['GET'])
 @common.require_accepts_json
-@admin_required
+@login_required
 def list_surveys() -> Any:
     """
     retrieve a list of surveys and responses, including their status
@@ -58,6 +59,10 @@ def list_surveys() -> Any:
         compendium_v2.routes.survey.LIST_SURVEYS_RESPONSE_SCHEMA
 
     """
+
+    if not (current_user.is_admin or current_user.is_observer):
+        return {'message': 'Insufficient privileges to access this resource'}, 403
+
     surveys = db.session.scalars(
         select(Survey).options(
             load_only(Survey.year, Survey.status),
@@ -74,21 +79,26 @@ def list_surveys() -> Any:
         status: str
         responses: List[Dict[str, str]]
 
-    entries: List[SurveyDict] = [
-        {
-            "year": entry.year,
-            "status": entry.status.value,
-            "responses": [
-                {
-                    "nren": r.nren.name,
-                    "status": r.status.value,
-                    "lock_description": r.lock_description
-                }
-                for r in sorted(entry.responses, key=response_key)
-            ]
+    entries: List[SurveyDict] = []
+
+    def _get_response(response: SurveyResponse) -> Dict[str, str]:
+        res = {
+            "nren": response.nren.name,
+            "status": response.status.value,
+            "lock_description": response.lock_description
         }
-        for entry in surveys
-    ]
+        if current_user.is_observer:
+            res["lock_description"] = response.lock_description
+        return res
+    
+    for entry in surveys:
+        # only include lock description if the user is an admin
+        entries.append(
+            {
+                "year": entry.year,
+                "status": entry.status.value,
+                "responses": [_get_response(r) for r in sorted(entry.responses, key=response_key)]
+            })
 
     # add in nrens without a response if the survey is open
     nren_names = set([name for name in db.session.scalars(select(NREN.name))])