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 +
+
+
+ Table Management + Admin +
+
+ + +
+

Permissions Management

+

Review access requests and manage user permissions for datasets

+
+ + +
+ + +
+
+
+
+ + + + +
+
+
Pending Access Requests
+
Review and approve or deny user requests
+
+
+ +
+
+
+
+ Loading requests... +
+
+
+ + +
+
+
+
+ + + + +
+
+
User Permissions
+
All granted dataset permissions
+
+
+
+ + +
+
+
+
+
+ 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 @@
- Admin + Permissions + Admin
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 => { })(); + +
+
+ + + +
+
+ + +
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