From 0bef44a29211c0485a9fee600b8dd9e02fb5c2c0 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Thu, 8 Jan 2026 18:26:22 -0600 Subject: [PATCH] Add access permission management API Backend: - Add AccessPermission schemas (Create, Update, Response) - Add ProjectWithAccessResponse schema - Add permission endpoints: - GET /project/{name}/permissions - list permissions (admin only) - POST /project/{name}/permissions - grant access (admin only) - PUT /project/{name}/permissions/{username} - update access - DELETE /project/{name}/permissions/{username} - revoke access - GET /project/{name}/my-access - get current user's access level Frontend: - Add AccessLevel, AccessPermission types - Add API functions for access management: - getMyProjectAccess() - listProjectPermissions() - grantProjectAccess() - updateProjectAccess() - revokeProjectAccess() --- backend/app/routes.py | 156 +++++++++++++++++++++++++++++++++++++++++ backend/app/schemas.py | 46 ++++++++++++ frontend/src/api.ts | 63 +++++++++++++++++ frontend/src/types.ts | 35 +++++++++ 4 files changed, 300 insertions(+) diff --git a/backend/app/routes.py b/backend/app/routes.py index 8935e21..27ccb81 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -107,6 +107,9 @@ from .schemas import ( APIKeyCreate, APIKeyResponse, APIKeyCreateResponse, + AccessPermissionCreate, + AccessPermissionUpdate, + AccessPermissionResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -1190,6 +1193,159 @@ def delete_project( return None +# Access Permission routes +@router.get( + "/api/v1/project/{project_name}/permissions", + response_model=List[AccessPermissionResponse], +) +def list_project_permissions( + project_name: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + List all access permissions for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + permissions = auth_service.list_project_permissions(str(project.id)) + + return permissions + + +@router.post( + "/api/v1/project/{project_name}/permissions", + response_model=AccessPermissionResponse, +) +def grant_project_access( + project_name: str, + permission: AccessPermissionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Grant access to a user for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + new_permission = auth_service.grant_access( + str(project.id), + permission.username, + permission.level, + permission.expires_at, + ) + + return new_permission + + +@router.put( + "/api/v1/project/{project_name}/permissions/{username}", + response_model=AccessPermissionResponse, +) +def update_project_access( + project_name: str, + username: str, + permission: AccessPermissionUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Update a user's access level for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + + # Get existing permission + from .models import AccessPermission + existing = ( + db.query(AccessPermission) + .filter( + AccessPermission.project_id == project.id, + AccessPermission.user_id == username, + ) + .first() + ) + + if not existing: + raise HTTPException( + status_code=404, + detail=f"No access permission found for user '{username}'", + ) + + # Update fields + if permission.level is not None: + existing.level = permission.level + if permission.expires_at is not None: + existing.expires_at = permission.expires_at + + db.commit() + db.refresh(existing) + + return existing + + +@router.delete( + "/api/v1/project/{project_name}/permissions/{username}", + status_code=204, +) +def revoke_project_access( + project_name: str, + username: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Revoke a user's access to a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + deleted = auth_service.revoke_access(str(project.id), username) + + if not deleted: + raise HTTPException( + status_code=404, + detail=f"No access permission found for user '{username}'", + ) + + return None + + +@router.get( + "/api/v1/project/{project_name}/my-access", +) +def get_my_project_access( + project_name: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), +): + """ + Get the current user's access level for a project. + Returns null for anonymous users on private projects. + """ + from .models import Project + + project = db.query(Project).filter(Project.name == project_name).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + auth_service = AuthorizationService(db) + access_level = auth_service.get_user_access_level(str(project.id), current_user) + + return { + "project": project_name, + "access_level": access_level, + "is_owner": current_user and project.created_by == current_user.username, + } + + # Package routes @router.get( "/api/v1/project/{project_name}/packages", diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 70bbda3..dd9ef13 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -776,3 +776,49 @@ class APIKeyCreateResponse(BaseModel): created_at: datetime expires_at: Optional[datetime] + +# Access Permission schemas +class AccessPermissionCreate(BaseModel): + """Grant access to a user for a project""" + username: str + level: str # 'read', 'write', or 'admin' + expires_at: Optional[datetime] = None + + @field_validator('level') + @classmethod + def validate_level(cls, v): + if v not in ('read', 'write', 'admin'): + raise ValueError("level must be 'read', 'write', or 'admin'") + return v + + +class AccessPermissionUpdate(BaseModel): + """Update access permission""" + level: Optional[str] = None + expires_at: Optional[datetime] = None + + @field_validator('level') + @classmethod + def validate_level(cls, v): + if v is not None and v not in ('read', 'write', 'admin'): + raise ValueError("level must be 'read', 'write', or 'admin'") + return v + + +class AccessPermissionResponse(BaseModel): + """Access permission response""" + id: UUID + project_id: UUID + user_id: str + level: str + created_at: datetime + expires_at: Optional[datetime] + + class Config: + from_attributes = True + + +class ProjectWithAccessResponse(ProjectResponse): + """Project response with user's access level""" + user_access_level: Optional[str] = None + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 155d3fa..f9adafe 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -25,6 +25,10 @@ import { AdminUser, UserCreate, UserUpdate, + AccessPermission, + AccessPermissionCreate, + AccessPermissionUpdate, + AccessLevel, } from './types'; const API_BASE = '/api/v1'; @@ -299,3 +303,62 @@ export async function resetUserPassword(username: string, newPassword: string): throw new Error(error.detail || `HTTP ${response.status}`); } } + +// Access Permission API +export interface MyAccessResponse { + project: string; + access_level: AccessLevel | null; + is_owner: boolean; +} + +export async function getMyProjectAccess(projectName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/my-access`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function listProjectPermissions(projectName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function grantProjectAccess( + projectName: string, + data: AccessPermissionCreate +): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateProjectAccess( + projectName: string, + username: string, + data: AccessPermissionUpdate +): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function revokeProjectAccess(projectName: string, username: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e1076a5..84d6fdd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -289,3 +289,38 @@ export interface UserUpdate { is_admin?: boolean; is_active?: boolean; } + +// Access Control types +export type AccessLevel = 'read' | 'write' | 'admin'; + +export interface AccessPermission { + id: string; + project_id: string; + user_id: string; + level: AccessLevel; + created_at: string; + expires_at: string | null; +} + +export interface AccessPermissionCreate { + username: string; + level: AccessLevel; + expires_at?: string; +} + +export interface AccessPermissionUpdate { + level?: AccessLevel; + expires_at?: string | null; +} + +// Extended Project with user's access level +export interface ProjectWithAccess extends Project { + user_access_level?: AccessLevel; +} + +// Current user with permissions context +export interface CurrentUser extends User { + permissions?: { + [projectId: string]: AccessLevel; + }; +}