|
| 1 | +from datetime import UTC, datetime |
| 2 | + |
| 3 | +from fastapi import APIRouter, Depends, HTTPException, status |
| 4 | +from sqlalchemy import select |
| 5 | +from sqlalchemy.ext.asyncio import AsyncSession |
| 6 | + |
| 7 | +from backend.database import get_db |
| 8 | +from backend.models.consumer_grant import ConsumerGrant |
| 9 | +from backend.models.identity import ServiceIdentity |
| 10 | +from backend.schemas.common import ApiResponse |
| 11 | +from backend.schemas.consumer_grant import ( |
| 12 | + ConsumerGrantCreate, |
| 13 | + ConsumerGrantPatch, |
| 14 | + ConsumerGrantRead, |
| 15 | + ConsumerGrantRevoke, |
| 16 | +) |
| 17 | +from backend.security.identity import RequestIdentity, get_request_identity |
| 18 | +from backend.security.workspace_rbac import ( |
| 19 | + WorkspacePermission, |
| 20 | + get_workspace_access, |
| 21 | + require_permission, |
| 22 | +) |
| 23 | + |
| 24 | +router = APIRouter(prefix="/workspaces/{workspace_id}/consumer-grants", tags=["consumer-grants"]) |
| 25 | + |
| 26 | + |
| 27 | +def _read_grant(grant: ConsumerGrant) -> ConsumerGrantRead: |
| 28 | + if grant.revoked_at is not None: |
| 29 | + grant_status = "revoked" |
| 30 | + else: |
| 31 | + grant_status = "enabled" if grant.enabled else "disabled" |
| 32 | + return ConsumerGrantRead( |
| 33 | + id=grant.id, |
| 34 | + service_identity_id=grant.service_identity_id, |
| 35 | + name=grant.name, |
| 36 | + resource_scope=grant.resource_scope, |
| 37 | + data_scope=grant.data_scope, |
| 38 | + quota=grant.quota, |
| 39 | + status=grant_status, |
| 40 | + enabled=grant.enabled, |
| 41 | + created_by_user_id=grant.created_by_user_id, |
| 42 | + revoked_at=grant.revoked_at, |
| 43 | + revoked_by_user_id=grant.revoked_by_user_id, |
| 44 | + revocation_reason=grant.revocation_reason, |
| 45 | + created_at=grant.created_at, |
| 46 | + updated_at=grant.updated_at, |
| 47 | + ) |
| 48 | + |
| 49 | + |
| 50 | +async def _get_grant( |
| 51 | + db: AsyncSession, workspace_id: str, grant_id: str, *, lock: bool = False |
| 52 | +) -> ConsumerGrant: |
| 53 | + query = ( |
| 54 | + select(ConsumerGrant) |
| 55 | + .join(ServiceIdentity, ServiceIdentity.id == ConsumerGrant.service_identity_id) |
| 56 | + .where(ServiceIdentity.workspace_id == workspace_id) |
| 57 | + .where(ConsumerGrant.id == grant_id) |
| 58 | + ) |
| 59 | + if lock: |
| 60 | + query = query.with_for_update() |
| 61 | + grant = await db.scalar(query) |
| 62 | + if grant is None: |
| 63 | + raise HTTPException(status.HTTP_404_NOT_FOUND, "Consumer Grant not found") |
| 64 | + return grant |
| 65 | + |
| 66 | + |
| 67 | +@router.get("", response_model=ApiResponse[list[ConsumerGrantRead]]) |
| 68 | +async def list_consumer_grants( |
| 69 | + workspace_id: str, |
| 70 | + identity: RequestIdentity = Depends(get_request_identity), |
| 71 | + db: AsyncSession = Depends(get_db), |
| 72 | +) -> ApiResponse: |
| 73 | + access = await get_workspace_access(db, workspace_id, identity) |
| 74 | + require_permission(access, WorkspacePermission.READ) |
| 75 | + grants = ( |
| 76 | + ( |
| 77 | + await db.execute( |
| 78 | + select(ConsumerGrant) |
| 79 | + .join( |
| 80 | + ServiceIdentity, |
| 81 | + ServiceIdentity.id == ConsumerGrant.service_identity_id, |
| 82 | + ) |
| 83 | + .where(ServiceIdentity.workspace_id == workspace_id) |
| 84 | + .order_by(ConsumerGrant.created_at) |
| 85 | + ) |
| 86 | + ) |
| 87 | + .scalars() |
| 88 | + .all() |
| 89 | + ) |
| 90 | + return ApiResponse.ok([_read_grant(grant) for grant in grants]) |
| 91 | + |
| 92 | + |
| 93 | +@router.post("", response_model=ApiResponse[ConsumerGrantRead], status_code=201) |
| 94 | +async def create_consumer_grant( |
| 95 | + workspace_id: str, |
| 96 | + body: ConsumerGrantCreate, |
| 97 | + identity: RequestIdentity = Depends(get_request_identity), |
| 98 | + db: AsyncSession = Depends(get_db), |
| 99 | +) -> ApiResponse: |
| 100 | + access = await get_workspace_access(db, workspace_id, identity) |
| 101 | + require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS) |
| 102 | + service_identity = await db.scalar( |
| 103 | + select(ServiceIdentity) |
| 104 | + .where(ServiceIdentity.id == body.service_identity_id) |
| 105 | + .where(ServiceIdentity.workspace_id == workspace_id) |
| 106 | + ) |
| 107 | + if service_identity is None: |
| 108 | + raise HTTPException( |
| 109 | + status.HTTP_422_UNPROCESSABLE_CONTENT, |
| 110 | + "Service Identity must belong to Workspace", |
| 111 | + ) |
| 112 | + if service_identity.disabled: |
| 113 | + raise HTTPException( |
| 114 | + status.HTTP_409_CONFLICT, |
| 115 | + "Disabled Service Identity cannot receive a Consumer Grant", |
| 116 | + ) |
| 117 | + existing = await db.scalar( |
| 118 | + select(ConsumerGrant) |
| 119 | + .where(ConsumerGrant.service_identity_id == service_identity.id) |
| 120 | + .where(ConsumerGrant.name == body.name) |
| 121 | + ) |
| 122 | + if existing is not None: |
| 123 | + raise HTTPException(status.HTTP_409_CONFLICT, "Consumer Grant name already exists") |
| 124 | + |
| 125 | + grant = ConsumerGrant( |
| 126 | + service_identity_id=service_identity.id, |
| 127 | + name=body.name, |
| 128 | + resource_scope=body.resource_scope.model_dump(mode="json"), |
| 129 | + data_scope=body.data_scope.model_dump(mode="json"), |
| 130 | + quota=body.quota.model_dump(mode="json"), |
| 131 | + created_by_user_id=access.user_id, |
| 132 | + ) |
| 133 | + db.add(grant) |
| 134 | + await db.flush() |
| 135 | + return ApiResponse.ok(_read_grant(grant)) |
| 136 | + |
| 137 | + |
| 138 | +@router.patch("/{grant_id}", response_model=ApiResponse[ConsumerGrantRead]) |
| 139 | +async def patch_consumer_grant( |
| 140 | + workspace_id: str, |
| 141 | + grant_id: str, |
| 142 | + body: ConsumerGrantPatch, |
| 143 | + identity: RequestIdentity = Depends(get_request_identity), |
| 144 | + db: AsyncSession = Depends(get_db), |
| 145 | +) -> ApiResponse: |
| 146 | + access = await get_workspace_access(db, workspace_id, identity) |
| 147 | + require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS) |
| 148 | + grant = await _get_grant(db, workspace_id, grant_id, lock=True) |
| 149 | + if grant.revoked_at is not None: |
| 150 | + raise HTTPException(status.HTTP_409_CONFLICT, "Revoked Consumer Grant cannot be changed") |
| 151 | + grant.enabled = body.enabled |
| 152 | + await db.flush() |
| 153 | + return ApiResponse.ok(_read_grant(grant)) |
| 154 | + |
| 155 | + |
| 156 | +@router.post("/{grant_id}/revoke", response_model=ApiResponse[ConsumerGrantRead]) |
| 157 | +async def revoke_consumer_grant( |
| 158 | + workspace_id: str, |
| 159 | + grant_id: str, |
| 160 | + body: ConsumerGrantRevoke, |
| 161 | + identity: RequestIdentity = Depends(get_request_identity), |
| 162 | + db: AsyncSession = Depends(get_db), |
| 163 | +) -> ApiResponse: |
| 164 | + access = await get_workspace_access(db, workspace_id, identity) |
| 165 | + require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS) |
| 166 | + grant = await _get_grant(db, workspace_id, grant_id, lock=True) |
| 167 | + if grant.revoked_at is not None: |
| 168 | + raise HTTPException(status.HTTP_409_CONFLICT, "Consumer Grant is already revoked") |
| 169 | + grant.enabled = False |
| 170 | + grant.revoked_at = datetime.now(UTC) |
| 171 | + grant.revoked_by_user_id = access.user_id |
| 172 | + grant.revocation_reason = body.reason |
| 173 | + await db.flush() |
| 174 | + return ApiResponse.ok(_read_grant(grant)) |
0 commit comments