diff --git a/app/api/access_requests.py b/app/api/access_requests.py
new file mode 100644
index 0000000..13cfc0e
--- /dev/null
+++ b/app/api/access_requests.py
@@ -0,0 +1,97 @@
+"""Access request API — users request access, admins approve/deny."""
+
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+import duckdb
+
+from app.auth.dependencies import get_current_user, require_role, Role, _get_db
+from src.repositories.access_requests import AccessRequestRepository
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/access-requests", tags=["access-requests"])
+
+
+class AccessRequestCreate(BaseModel):
+ table_id: str
+ reason: Optional[str] = ""
+
+
+@router.post("", status_code=201)
+async def create_request(
+ request: AccessRequestCreate,
+ user: dict = Depends(get_current_user),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Submit an access request for a table."""
+ repo = AccessRequestRepository(conn)
+
+ # Check for duplicate pending request
+ if repo.has_pending_request(user["id"], request.table_id):
+ raise HTTPException(status_code=409, detail="You already have a pending request for this table")
+
+ req_id = repo.create(
+ user_id=user["id"],
+ user_email=user.get("email", ""),
+ table_id=request.table_id,
+ reason=request.reason or "",
+ )
+ return {"id": req_id, "status": "pending", "table_id": request.table_id}
+
+
+@router.get("/my")
+async def my_requests(
+ user: dict = Depends(get_current_user),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """List current user's access requests."""
+ repo = AccessRequestRepository(conn)
+ requests = repo.list_by_user(user["id"])
+ # Serialize timestamps
+ for r in requests:
+ for k in ("created_at", "reviewed_at"):
+ if r.get(k) and hasattr(r[k], "isoformat"):
+ r[k] = r[k].isoformat()
+ return {"requests": requests}
+
+
+@router.get("/pending")
+async def pending_requests(
+ user: dict = Depends(require_role(Role.ADMIN)),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """List all pending access requests (admin only)."""
+ repo = AccessRequestRepository(conn)
+ requests = repo.list_pending()
+ for r in requests:
+ for k in ("created_at", "reviewed_at"):
+ if r.get(k) and hasattr(r[k], "isoformat"):
+ r[k] = r[k].isoformat()
+ return {"requests": requests, "count": len(requests)}
+
+
+@router.post("/{request_id}/approve")
+async def approve_request(
+ request_id: str,
+ user: dict = Depends(require_role(Role.ADMIN)),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Approve an access request (admin only). Auto-grants permission."""
+ repo = AccessRequestRepository(conn)
+ if repo.approve(request_id, reviewed_by=user.get("email", "")):
+ return {"status": "approved", "id": request_id}
+ raise HTTPException(status_code=404, detail="Request not found or already processed")
+
+
+@router.post("/{request_id}/deny")
+async def deny_request(
+ request_id: str,
+ user: dict = Depends(require_role(Role.ADMIN)),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Deny an access request (admin only)."""
+ repo = AccessRequestRepository(conn)
+ if repo.deny(request_id, reviewed_by=user.get("email", "")):
+ return {"status": "denied", "id": request_id}
+ raise HTTPException(status_code=404, detail="Request not found or already processed")
diff --git a/app/main.py b/app/main.py
index db13e96..61e1a4f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -24,6 +24,7 @@ from app.api.catalog import router as catalog_router
from app.api.telegram import router as telegram_router
from app.api.admin import router as admin_router
from app.api.permissions import router as permissions_router
+from app.api.access_requests import router as access_requests_router
from app.web.router import router as web_router
logger = logging.getLogger(__name__)
@@ -103,6 +104,7 @@ def create_app() -> FastAPI:
app.include_router(telegram_router)
app.include_router(admin_router)
app.include_router(permissions_router)
+ app.include_router(access_requests_router)
# Web UI router (must be last — has catch-all routes)
app.include_router(web_router)
diff --git a/app/web/router.py b/app/web/router.py
index 2759134..c1d62cc 100644
--- a/app/web/router.py
+++ b/app/web/router.py
@@ -26,6 +26,7 @@ from src.repositories.sync_settings import SyncSettingsRepository, DatasetPermis
from src.repositories.knowledge import KnowledgeRepository
from src.repositories.users import UserRepository
from src.repositories.profiles import ProfileRepository
+from src.repositories.access_requests import AccessRequestRepository
logger = logging.getLogger(__name__)
router = APIRouter(tags=["web"])
@@ -240,27 +241,45 @@ async def catalog(
try:
from src.repositories.table_registry import TableRegistryRepository
table_repo = TableRegistryRepository(conn)
+ perm_repo = DatasetPermissionRepository(conn)
+ access_repo = AccessRequestRepository(conn)
registered = table_repo.list_all()
+
+ # Pre-fetch user's pending access requests
+ user_id = user.get("id", "")
+ user_requests = access_repo.list_by_user(user_id)
+ pending_request_table_ids = {
+ r["table_id"] for r in user_requests if r.get("status") == "pending"
+ }
+
tables = []
for tc in registered:
+ table_id = tc.get("id", "")
+ is_public = tc.get("is_public", True)
+ has_access = is_public or perm_repo.has_access(user_id, table_id)
+
table_data = {
- "id": tc.get("id", ""),
+ "id": table_id,
"name": tc.get("name", ""),
"description": tc.get("description", ""),
"dataset": tc.get("bucket"),
"sync_strategy": tc.get("sync_strategy", "full_refresh"),
"query_mode": tc.get("query_mode", "local"),
- "profile": all_profiles.get(tc.get("id", "")),
+ "profile": all_profiles.get(table_id),
+ "is_public": is_public,
+ "has_access": has_access,
+ "pending_request": table_id in pending_request_table_ids,
}
# Add sync state
for state in all_states:
- if state["table_id"] == tc.get("id"):
+ if state["table_id"] == table_id:
table_data["last_sync"] = state.get("last_sync")
table_data["rows"] = state.get("rows")
break
tables.append(table_data)
except Exception as e:
tables = []
+ pending_request_table_ids = set()
logger.warning(f"Could not load catalog: {e}")
# Build data_stats for catalog template
@@ -281,13 +300,20 @@ async def catalog(
categories[ds] = {"name": ds, "tables": []}
categories[ds]["tables"].append(t)
+ # Add count to each category (template expects .count)
+ catalog_data = []
+ for cat in categories.values():
+ cat["count"] = len(cat["tables"])
+ catalog_data.append(cat)
+
ctx = _build_context(
request, user=user,
tables=tables,
datasets=datasets,
enabled_datasets=enabled_datasets,
data_stats=data_stats,
- categories=list(categories.values()),
+ categories=catalog_data,
+ catalog_data=catalog_data,
metrics_data=[],
sync_states=all_states,
folder_mapping={},
@@ -391,3 +417,14 @@ async def admin_tables(
tables = repo.list_all()
ctx = _build_context(request, user=user, registered_tables=tables)
return templates.TemplateResponse(request, "admin_tables.html", ctx)
+
+
+@router.get("/admin/permissions", response_class=HTMLResponse)
+async def admin_permissions_page(
+ request: Request,
+ user: dict = Depends(get_current_user),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Admin page for managing permissions and access requests."""
+ ctx = _build_context(request, user=user)
+ return templates.TemplateResponse(request, "admin_permissions.html", ctx)
diff --git a/app/web/templates/admin_permissions.html b/app/web/templates/admin_permissions.html
new file mode 100644
index 0000000..1832d4f
--- /dev/null
+++ b/app/web/templates/admin_permissions.html
@@ -0,0 +1,1240 @@
+
+
+
+
+
+ Permissions Management - {{ config.INSTANCE_NAME }}
+ {% if not config.THEME_FONT_URL %}
+
+
+
+ {% endif %}
+
+ {% include '_theme.html' %}
+
+
+
+
+
+
+
+
+
Permissions Management
+
Review access requests and manage user permissions for datasets
+
+
+
+
+
+
+
+
+
+
+
+
Loading requests...
+
+
+
+
+
+
+
+
+
+
+
Loading permissions...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/web/templates/admin_tables.html b/app/web/templates/admin_tables.html
index 5c9d2df..d82603f 100644
--- a/app/web/templates/admin_tables.html
+++ b/app/web/templates/admin_tables.html
@@ -750,7 +750,8 @@
diff --git a/app/web/templates/catalog.html b/app/web/templates/catalog.html
index 421601b..ce6d888 100644
--- a/app/web/templates/catalog.html
+++ b/app/web/templates/catalog.html
@@ -373,6 +373,158 @@
background: rgba(243, 244, 246, 0.5);
}
+ .table-row-locked {
+ opacity: 0.75;
+ }
+
+ .table-row-locked:hover {
+ background: rgba(234, 88, 12, 0.04);
+ }
+
+ .access-badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 4px;
+ margin-left: 6px;
+ vertical-align: middle;
+ font-family: var(--font-primary);
+ }
+
+ .access-badge.locked {
+ font-size: 13px;
+ padding: 0 2px;
+ }
+
+ .access-badge.pending {
+ background: #FFF7ED;
+ color: #EA580C;
+ border: 1px solid #FDBA74;
+ }
+
+ .btn-request-access {
+ font-size: 12px;
+ font-weight: 500;
+ padding: 4px 12px;
+ border-radius: 6px;
+ background: var(--primary-light);
+ color: var(--primary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ }
+
+ .btn-request-access:hover {
+ background: var(--primary);
+ color: #fff;
+ }
+
+ /* Request Access Modal */
+ .request-access-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1100;
+ padding: 40px 24px;
+ }
+
+ .request-access-overlay.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .request-access-modal {
+ max-width: 500px;
+ width: 100%;
+ background: var(--surface);
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ overflow: hidden;
+ }
+
+ .request-access-modal .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px 24px 16px;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .request-access-modal .modal-header h3 {
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ .request-access-modal .modal-body {
+ padding: 20px 24px;
+ }
+
+ .request-access-modal .modal-body label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-top: 16px;
+ margin-bottom: 6px;
+ }
+
+ .request-access-modal .modal-body textarea {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-family: var(--font-primary);
+ font-size: 13px;
+ resize: vertical;
+ }
+
+ .request-access-modal .modal-body textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-light);
+ }
+
+ .request-access-modal .modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 16px 24px 20px;
+ border-top: 1px solid var(--border);
+ }
+
+ .request-access-modal .btn-secondary {
+ padding: 8px 16px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ color: var(--text-primary);
+ }
+
+ .request-access-modal .btn-primary {
+ padding: 8px 16px;
+ border-radius: 8px;
+ border: none;
+ background: var(--primary);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ }
+
+ .request-access-modal .btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
.table-row-left {
flex: 1;
min-width: 0;
@@ -1414,10 +1566,17 @@
{% for table in category.tables %}
-
+
{{ table.name }}
+ {% if not table.is_public and not table.has_access %}
+ {% if table.pending_request %}
+ Pending
+ {% else %}
+ 🔒
+ {% endif %}
+ {% endif %}
{% if table.query_mode == 'remote' %}
Live
{% else %}
@@ -1426,7 +1585,9 @@
{{ table.description }}
- {% if table.query_mode == 'remote' %}
+ {% if not table.is_public and not table.has_access and not table.pending_request %}
+ Click to request access
+ {% elif table.query_mode == 'remote' %}
Queried directly from BigQuery
{% elif table.last_sync %}
Synced {{ table.last_sync }}
@@ -1434,6 +1595,13 @@
+ {% if not table.is_public and not table.has_access %}
+ {% if table.pending_request %}
+ Awaiting review
+ {% else %}
+ Request Access
+ {% endif %}
+ {% else %}
{{ table.rows_display }}
{% if table.query_mode != 'remote' %}
@@ -1441,6 +1609,7 @@
Profile
{% endif %}
+ {% endif %}
{% endfor %}
@@ -2450,6 +2619,77 @@ document.addEventListener('keydown', e => {
})();
+
+
+
+
+
+
Table:
+
+
+
+
+
+
+
+
+
+
diff --git a/src/db.py b/src/db.py
index 036063a..46a6339 100644
--- a/src/db.py
+++ b/src/db.py
@@ -149,6 +149,18 @@ CREATE TABLE IF NOT EXISTS dataset_permissions (
access VARCHAR DEFAULT 'read',
PRIMARY KEY (user_id, dataset)
);
+
+CREATE TABLE IF NOT EXISTS access_requests (
+ id VARCHAR PRIMARY KEY,
+ user_id VARCHAR NOT NULL,
+ user_email VARCHAR NOT NULL,
+ table_id VARCHAR NOT NULL,
+ reason TEXT,
+ status VARCHAR DEFAULT 'pending',
+ reviewed_by VARCHAR,
+ reviewed_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT current_timestamp
+);
"""
diff --git a/src/repositories/access_requests.py b/src/repositories/access_requests.py
new file mode 100644
index 0000000..34754c7
--- /dev/null
+++ b/src/repositories/access_requests.py
@@ -0,0 +1,90 @@
+"""Repository for access requests."""
+
+import uuid
+from datetime import datetime, timezone
+from typing import Any, Optional, List, Dict
+
+import duckdb
+
+
+class AccessRequestRepository:
+ def __init__(self, conn: duckdb.DuckDBPyConnection):
+ self.conn = conn
+
+ def create(self, user_id: str, user_email: str, table_id: str, reason: str = "") -> str:
+ """Create a new access request. Returns request ID."""
+ req_id = str(uuid.uuid4())
+ now = datetime.now(timezone.utc)
+ self.conn.execute(
+ """INSERT INTO access_requests (id, user_id, user_email, table_id, reason, status, created_at)
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)""",
+ [req_id, user_id, user_email, table_id, reason, now],
+ )
+ return req_id
+
+ def get(self, request_id: str) -> Optional[Dict[str, Any]]:
+ result = self.conn.execute("SELECT * FROM access_requests WHERE id = ?", [request_id]).fetchone()
+ if not result:
+ return None
+ columns = [desc[0] for desc in self.conn.description]
+ return dict(zip(columns, result))
+
+ def list_pending(self) -> List[Dict[str, Any]]:
+ """List all pending requests (for admin)."""
+ results = self.conn.execute(
+ "SELECT * FROM access_requests WHERE status = 'pending' ORDER BY created_at DESC"
+ ).fetchall()
+ if not results:
+ return []
+ columns = [desc[0] for desc in self.conn.description]
+ return [dict(zip(columns, row)) for row in results]
+
+ def list_by_user(self, user_id: str) -> List[Dict[str, Any]]:
+ """List all requests by a user."""
+ results = self.conn.execute(
+ "SELECT * FROM access_requests WHERE user_id = ? ORDER BY created_at DESC",
+ [user_id],
+ ).fetchall()
+ if not results:
+ return []
+ columns = [desc[0] for desc in self.conn.description]
+ return [dict(zip(columns, row)) for row in results]
+
+ def approve(self, request_id: str, reviewed_by: str) -> bool:
+ """Approve request and grant access."""
+ req = self.get(request_id)
+ if not req or req["status"] != "pending":
+ return False
+
+ now = datetime.now(timezone.utc)
+ self.conn.execute(
+ "UPDATE access_requests SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
+ [reviewed_by, now, request_id],
+ )
+
+ # Auto-grant permission
+ from src.repositories.sync_settings import DatasetPermissionRepository
+ DatasetPermissionRepository(self.conn).grant(req["user_id"], req["table_id"], "read")
+
+ return True
+
+ def deny(self, request_id: str, reviewed_by: str) -> bool:
+ """Deny a request."""
+ req = self.get(request_id)
+ if not req or req["status"] != "pending":
+ return False
+
+ now = datetime.now(timezone.utc)
+ self.conn.execute(
+ "UPDATE access_requests SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
+ [reviewed_by, now, request_id],
+ )
+ return True
+
+ def has_pending_request(self, user_id: str, table_id: str) -> bool:
+ """Check if user already has a pending request for this table."""
+ result = self.conn.execute(
+ "SELECT id FROM access_requests WHERE user_id = ? AND table_id = ? AND status = 'pending'",
+ [user_id, table_id],
+ ).fetchone()
+ return result is not None