diff --git a/alembic/versions/2026_06_04_0930-8266bacba614_add_credential_folders.py b/alembic/versions/2026_06_04_0930-8266bacba614_add_credential_folders.py new file mode 100644 index 000000000..3f75c9da2 --- /dev/null +++ b/alembic/versions/2026_06_04_0930-8266bacba614_add_credential_folders.py @@ -0,0 +1,83 @@ +"""add credential folders + +Revision ID: 8266bacba614 +Revises: ed48b2c9cab2 +Create Date: 2026-06-04T09:30:15.687201+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8266bacba614" +down_revision: Union[str, None] = "ed48b2c9cab2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "credential_folders", + sa.Column("folder_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.organization_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("folder_id"), + ) + op.create_index("credential_folder_organization_id_idx", "credential_folders", ["organization_id"], unique=False) + op.create_index( + "credential_folder_organization_title_idx", + "credential_folders", + ["organization_id", "title"], + unique=False, + ) + + # ADD COLUMN is metadata-only on PG 11+ and the FK is added NOT VALID (no + # full-table scan), so both take only a brief ACCESS EXCLUSIVE lock bounded + # by lock_timeout. SET LOCAL auto-reverts at transaction end, so a rollback + # can't leave the short timeout lingering on the connection. + op.execute("SET LOCAL lock_timeout = '5s';") + op.add_column("credentials", sa.Column("folder_id", sa.String(), nullable=True)) + op.create_foreign_key( + "credentials_folder_id_fkey", + "credentials", + "credential_folders", + ["folder_id"], + ["folder_id"], + ondelete="SET NULL", + postgresql_not_valid=True, + ) + + # Validate outside the lock window — existing folder_id values are all NULL, + # so this scan is trivial and takes only a SHARE UPDATE EXCLUSIVE lock. + with op.get_context().autocommit_block(): + op.execute("ALTER TABLE credentials VALIDATE CONSTRAINT credentials_folder_id_fkey") + + # Build the folder_id lookup index without a long-held lock on credentials. + with op.get_context().autocommit_block(): + op.execute("SET statement_timeout = '1h';") + op.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS credential_folder_id_idx ON credentials (folder_id)") + op.execute("RESET statement_timeout;") + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("SET statement_timeout = '1h';") + op.execute("DROP INDEX CONCURRENTLY IF EXISTS credential_folder_id_idx") + op.execute("RESET statement_timeout;") + + op.execute("SET LOCAL lock_timeout = '5s';") + op.drop_constraint("credentials_folder_id_fkey", "credentials", type_="foreignkey") + op.drop_column("credentials", "folder_id") + + op.drop_index("credential_folder_organization_title_idx", table_name="credential_folders") + op.drop_index("credential_folder_organization_id_idx", table_name="credential_folders") + op.drop_table("credential_folders") diff --git a/fern/openapi/overrides.yml b/fern/openapi/overrides.yml index a32103399..24d162b79 100644 --- a/fern/openapi/overrides.yml +++ b/fern/openapi/overrides.yml @@ -9,3 +9,11 @@ paths: x-fern-ignore: true /api/v1/billing/change-tier: x-fern-ignore: true +components: + schemas: + # folder_id is returned at runtime for the internal folder UI, but credential + # folders are not part of the public API, so keep it out of the generated SDK. + CredentialResponse: + properties: + folder_id: + x-fern-ignore: true diff --git a/pyproject.toml b/pyproject.toml index 14dd88c25..0e30c6a15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "skyvern" -version = "1.0.38" +version = "1.0.37" description = "" authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }] requires-python = ">=3.11,<3.14" diff --git a/skyvern/forge/sdk/db/agent_db.py b/skyvern/forge/sdk/db/agent_db.py index 382b7d21b..33bea4a8c 100644 --- a/skyvern/forge/sdk/db/agent_db.py +++ b/skyvern/forge/sdk/db/agent_db.py @@ -17,6 +17,7 @@ from skyvern.forge.sdk.db.exceptions import ScheduleLimitExceededError # noqa: from skyvern.forge.sdk.db.models import PersistentBrowserSessionModel from skyvern.forge.sdk.db.repositories.artifacts import ArtifactsRepository from skyvern.forge.sdk.db.repositories.browser_sessions import BrowserSessionsRepository +from skyvern.forge.sdk.db.repositories.credential_folders import CredentialFoldersRepository from skyvern.forge.sdk.db.repositories.credentials import CredentialRepository from skyvern.forge.sdk.db.repositories.debug import DebugRepository from skyvern.forge.sdk.db.repositories.folders import FoldersRepository @@ -127,6 +128,7 @@ class AgentDB(BaseAlchemyDB): self.workflows = WorkflowsRepository(self.Session, debug_enabled, self.is_retryable_error) self.workflow_params = WorkflowParametersRepository(self.Session, debug_enabled, self.is_retryable_error) self.credentials = CredentialRepository(self.Session, debug_enabled, self.is_retryable_error) + self.credential_folders = CredentialFoldersRepository(self.Session, debug_enabled, self.is_retryable_error) self.otp = OTPRepository(self.Session, debug_enabled, self.is_retryable_error) self.debug = DebugRepository(self.Session, debug_enabled, self.is_retryable_error) self.organizations = OrganizationsRepository(self.Session, debug_enabled, self.is_retryable_error) diff --git a/skyvern/forge/sdk/db/id.py b/skyvern/forge/sdk/db/id.py index 4b2af1fb4..c1a4d4c5b 100644 --- a/skyvern/forge/sdk/db/id.py +++ b/skyvern/forge/sdk/db/id.py @@ -41,6 +41,7 @@ CREDENTIAL_ONEPASSWORD_PARAMETER_PREFIX = "opp" CREDENTIAL_AZURE_VAULT_PARAMETER_PREFIX = "azcp" CREDENTIAL_PARAMETER_PREFIX = "cp" CREDENTIAL_PREFIX = "cred" +CREDENTIAL_FOLDER_PREFIX = "cfld" DEBUG_SESSION_PREFIX = "ds" FOLDER_PREFIX = "fld" BROWSER_PROFILE_PREFIX = "bp" @@ -249,6 +250,11 @@ def generate_folder_id() -> str: return f"{FOLDER_PREFIX}_{int_id}" +def generate_credential_folder_id() -> str: + int_id = generate_id() + return f"{CREDENTIAL_FOLDER_PREFIX}_{int_id}" + + def generate_tag_event_id() -> str: int_id = generate_id() return f"{TAG_EVENT_PREFIX}_{int_id}" diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index bb7ad6a5e..6e8a227c7 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -34,6 +34,7 @@ from skyvern.forge.sdk.db.id import ( generate_bitwarden_login_credential_parameter_id, generate_bitwarden_sensitive_information_parameter_id, generate_browser_profile_id, + generate_credential_folder_id, generate_credential_id, generate_credential_parameter_id, generate_debug_session_id, @@ -1229,8 +1230,31 @@ class OrganizationBitwardenCollectionModel(Base): deleted_at = Column(DateTime, nullable=True) +class CredentialFolderModel(Base): + __tablename__ = "credential_folders" + __table_args__ = ( + Index("credential_folder_organization_id_idx", "organization_id"), + Index("credential_folder_organization_title_idx", "organization_id", "title"), + ) + + folder_id = Column(String, primary_key=True, default=generate_credential_folder_id) + organization_id = Column(String, ForeignKey("organizations.organization_id", ondelete="CASCADE"), nullable=False) + title = Column(String, nullable=False) + description = Column(String, nullable=True) + + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + modified_at = Column( + DateTime, + default=datetime.datetime.utcnow, + onupdate=datetime.datetime.utcnow, + nullable=False, + ) + deleted_at = Column(DateTime, nullable=True) + + class CredentialModel(Base): __tablename__ = "credentials" + __table_args__ = (Index("credential_folder_id_idx", "folder_id"),) credential_id = Column(String, primary_key=True, default=generate_credential_id) organization_id = Column(String, nullable=False) @@ -1249,6 +1273,7 @@ class CredentialModel(Base): tested_url = Column(String, nullable=True) user_context = Column(String(1000), nullable=True) save_browser_session_intent = Column(Boolean, nullable=True, default=False) + folder_id = Column(String, ForeignKey("credential_folders.folder_id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) diff --git a/skyvern/forge/sdk/db/repositories/credential_folders.py b/skyvern/forge/sdk/db/repositories/credential_folders.py new file mode 100644 index 000000000..3ef3f60a5 --- /dev/null +++ b/skyvern/forge/sdk/db/repositories/credential_folders.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import func, or_, select, update + +from skyvern.forge.sdk.db._error_handling import db_operation +from skyvern.forge.sdk.db.base_repository import BaseRepository +from skyvern.forge.sdk.db.models import CredentialFolderModel, CredentialModel +from skyvern.forge.sdk.schemas.credentials import Credential + + +class CredentialFoldersRepository(BaseRepository): + """Database operations for credential folder management.""" + + @db_operation("create_credential_folder") + async def create_credential_folder( + self, + organization_id: str, + title: str, + description: str | None = None, + ) -> CredentialFolderModel: + async with self.Session() as session: + folder = CredentialFolderModel( + organization_id=organization_id, + title=title, + description=description, + ) + session.add(folder) + await session.commit() + await session.refresh(folder) + return folder + + @db_operation("get_credential_folders") + async def get_credential_folders( + self, + organization_id: str, + page: int = 1, + page_size: int = 10, + search_query: str | None = None, + ) -> list[CredentialFolderModel]: + async with self.Session() as session: + stmt = ( + select(CredentialFolderModel) + .filter_by(organization_id=organization_id) + .filter(CredentialFolderModel.deleted_at.is_(None)) + ) + + if search_query: + escaped = search_query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + search_pattern = f"%{escaped}%" + stmt = stmt.filter( + or_( + CredentialFolderModel.title.ilike(search_pattern, escape="\\"), + CredentialFolderModel.description.ilike(search_pattern, escape="\\"), + ) + ) + + stmt = stmt.order_by(CredentialFolderModel.modified_at.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await session.execute(stmt) + return list(result.scalars().all()) + + @db_operation("get_credential_folder") + async def get_credential_folder( + self, + folder_id: str, + organization_id: str, + ) -> CredentialFolderModel | None: + async with self.Session() as session: + stmt = ( + select(CredentialFolderModel) + .filter_by(folder_id=folder_id, organization_id=organization_id) + .filter(CredentialFolderModel.deleted_at.is_(None)) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + @db_operation("update_credential_folder") + async def update_credential_folder( + self, + folder_id: str, + organization_id: str, + title: str | None = None, + description: str | None = None, + ) -> CredentialFolderModel | None: + async with self.Session() as session: + stmt = ( + select(CredentialFolderModel) + .filter_by(folder_id=folder_id, organization_id=organization_id) + .filter(CredentialFolderModel.deleted_at.is_(None)) + ) + result = await session.execute(stmt) + folder = result.scalar_one_or_none() + if not folder: + return None + + if title is not None: + folder.title = title + if description is not None: + folder.description = description + + folder.modified_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(folder) + return folder + + @db_operation("soft_delete_credential_folder") + async def soft_delete_credential_folder( + self, + folder_id: str, + organization_id: str, + ) -> bool: + """Soft delete a credential folder and detach its credentials (folder_id -> NULL). + + Credentials are intentionally never deleted here: they hold vault-backed secret + material whose removal must go through the credential vault service. Deleting a + folder only unfiles its credentials so no secrets are orphaned. + """ + async with self.Session() as session: + folder_stmt = ( + select(CredentialFolderModel) + .filter_by(folder_id=folder_id, organization_id=organization_id) + .filter(CredentialFolderModel.deleted_at.is_(None)) + ) + folder_result = await session.execute(folder_stmt) + folder = folder_result.scalar_one_or_none() + if not folder: + return False + + await session.execute( + update(CredentialModel) + .where(CredentialModel.folder_id == folder_id) + .where(CredentialModel.organization_id == organization_id) + .values(folder_id=None, modified_at=datetime.now(timezone.utc)) + ) + + folder.deleted_at = datetime.now(timezone.utc) + await session.commit() + return True + + @db_operation("get_credential_folder_credential_count") + async def get_credential_folder_credential_count( + self, + folder_id: str, + organization_id: str, + ) -> int: + async with self.Session() as session: + stmt = ( + select(func.count(CredentialModel.credential_id)) + .where(CredentialModel.folder_id == folder_id) + .where(CredentialModel.organization_id == organization_id) + .where(CredentialModel.deleted_at.is_(None)) + ) + result = await session.execute(stmt) + return result.scalar_one() + + @db_operation("get_credential_folder_credential_counts_batch") + async def get_credential_folder_credential_counts_batch( + self, + folder_ids: list[str], + organization_id: str, + ) -> dict[str, int]: + async with self.Session() as session: + stmt = ( + select( + CredentialModel.folder_id, + func.count(CredentialModel.credential_id).label("count"), + ) + .where(CredentialModel.folder_id.in_(folder_ids)) + .where(CredentialModel.organization_id == organization_id) + .where(CredentialModel.deleted_at.is_(None)) + .group_by(CredentialModel.folder_id) + ) + result = await session.execute(stmt) + rows = result.all() + + # Unpack positionally: Row.count would resolve to the tuple method, not the labeled column. + # Folders with no credentials are absent from the result. + return {folder_id: count for folder_id, count in rows} + + @db_operation("set_credential_folder") + async def set_credential_folder( + self, + credential_id: str, + organization_id: str, + folder_id: str | None, + ) -> Credential | None: + # Treat an empty folder_id as "remove from folder" (None) so it isn't + # written as a non-existent "" id and trip the folder foreign key. + folder_id = folder_id or None + async with self.Session() as session: + credential = await session.get(CredentialModel, credential_id) + if credential is None or credential.organization_id != organization_id or credential.deleted_at is not None: + return None + + # Validate the target folder in-org and bump its modified_at in a single fetch + if folder_id: + folder_model = await session.get(CredentialFolderModel, folder_id) + if ( + folder_model is None + or folder_model.organization_id != organization_id + or folder_model.deleted_at is not None + ): + raise ValueError(f"Credential folder {folder_id} not found") + folder_model.modified_at = datetime.now(timezone.utc) + + credential.folder_id = folder_id + credential.modified_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(credential) + return Credential.model_validate(credential) diff --git a/skyvern/forge/sdk/db/repositories/credentials.py b/skyvern/forge/sdk/db/repositories/credentials.py index 70873fdd3..5d0c01c0c 100644 --- a/skyvern/forge/sdk/db/repositories/credentials.py +++ b/skyvern/forge/sdk/db/repositories/credentials.py @@ -88,6 +88,7 @@ class CredentialRepository(BaseRepository): vault_type: str | None = None, credential_type: str | None = None, search: str | None = None, + folder_id: str | None = None, ) -> list[Credential]: async with self.Session() as session: query = ( @@ -99,6 +100,8 @@ class CredentialRepository(BaseRepository): query = query.filter(CredentialModel.vault_type == vault_type) if credential_type is not None: query = query.filter(CredentialModel.credential_type == credential_type) + if folder_id is not None: + query = query.filter(CredentialModel.folder_id == folder_id) if search: # Escape LIKE wildcards so a literal % or _ in the query narrows rather than broadens. escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 5119f65d3..46f0c05ad 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -38,6 +38,7 @@ from skyvern.exceptions import SkyvernHTTPException from skyvern.forge import app from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_request from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType, WorkflowRunTriggerType +from skyvern.forge.sdk.db.models import CredentialFolderModel from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory from skyvern.forge.sdk.routes.code_samples import ( CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_PYTHON, @@ -95,6 +96,12 @@ from skyvern.forge.sdk.services.bitwarden import BitwardenService from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService from skyvern.forge.sdk.workflow.models.parameter import WorkflowParameterType from skyvern.forge.sdk.workflow.models.workflow import WorkflowRequestBody, WorkflowRunStatus +from skyvern.schemas.credential_folders import ( + CredentialFolder, + CredentialFolderCreate, + CredentialFolderUpdate, + UpdateCredentialFolderRequest, +) from skyvern.schemas.workflows import ( LoginBlockYAML, WorkflowCreateYAMLRequest, @@ -1470,6 +1477,12 @@ async def get_credentials( max_length=200, description="Case-insensitive search across credential name, username, secret label, and card details", ), + folder_id: str | None = Query( + default=None, + description="Filter credentials by folder ID", + examples=["cfld_1234567890"], + include_in_schema=False, + ), ) -> list[CredentialResponse]: """Return non-sensitive metadata for all credentials (paginated). @@ -1483,10 +1496,216 @@ async def get_credentials( vault_type=vault_type.value if isinstance(vault_type, CredentialVaultType) else None, credential_type=credential_type.value if isinstance(credential_type, CredentialType) else None, search=search if isinstance(search, str) else None, + folder_id=folder_id if isinstance(folder_id, str) else None, ) return [_convert_to_response(credential) for credential in credentials] +def _to_credential_folder_response(folder: CredentialFolderModel, credential_count: int) -> CredentialFolder: + return CredentialFolder( + folder_id=folder.folder_id, + organization_id=folder.organization_id, + title=folder.title, + description=folder.description, + credential_count=credential_count, + created_at=folder.created_at, + modified_at=folder.modified_at, + ) + + +@legacy_base_router.post("/credential_folders") +@legacy_base_router.post("/credential_folders/", include_in_schema=False) +@base_router.post( + "/credential_folders", + response_model=CredentialFolder, + summary="Create credential folder", + description="Create a new folder to organize credentials", + include_in_schema=False, + responses={ + 200: {"description": "Successfully created credential folder"}, + 400: {"description": "Invalid request"}, + }, +) +@base_router.post("/credential_folders/", response_model=CredentialFolder, include_in_schema=False) +async def create_credential_folder( + data: CredentialFolderCreate = Body(..., description="The credential folder to create"), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CredentialFolder: + folder = await app.DATABASE.credential_folders.create_credential_folder( + organization_id=current_org.organization_id, + title=data.title, + description=data.description, + ) + return _to_credential_folder_response(folder, 0) + + +@legacy_base_router.get("/credential_folders/{folder_id}") +@legacy_base_router.get("/credential_folders/{folder_id}/", include_in_schema=False) +@base_router.get( + "/credential_folders/{folder_id}", + response_model=CredentialFolder, + summary="Get credential folder", + description="Get a specific credential folder by ID", + include_in_schema=False, + responses={ + 200: {"description": "Successfully retrieved credential folder"}, + 404: {"description": "Credential folder not found"}, + }, +) +@base_router.get("/credential_folders/{folder_id}/", response_model=CredentialFolder, include_in_schema=False) +async def get_credential_folder( + folder_id: str = Path(..., description="Credential folder ID", examples=["cfld_123"]), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CredentialFolder: + folder = await app.DATABASE.credential_folders.get_credential_folder( + folder_id=folder_id, + organization_id=current_org.organization_id, + ) + if not folder: + raise HTTPException(status_code=404, detail=f"Credential folder {folder_id} not found") + + credential_count = await app.DATABASE.credential_folders.get_credential_folder_credential_count( + folder_id=folder.folder_id, + organization_id=current_org.organization_id, + ) + return _to_credential_folder_response(folder, credential_count) + + +@legacy_base_router.get("/credential_folders") +@legacy_base_router.get("/credential_folders/", include_in_schema=False) +@base_router.get( + "/credential_folders", + response_model=list[CredentialFolder], + summary="Get credential folders", + description="Get all credential folders for the organization", + include_in_schema=False, + responses={ + 200: {"description": "Successfully retrieved credential folders"}, + }, +) +@base_router.get("/credential_folders/", response_model=list[CredentialFolder], include_in_schema=False) +async def get_credential_folders( + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(100, ge=1, le=500, description="Number of folders per page"), + search: str | None = Query(None, description="Search folders by title or description"), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> list[CredentialFolder]: + folders = await app.DATABASE.credential_folders.get_credential_folders( + organization_id=current_org.organization_id, + page=page, + page_size=page_size, + search_query=search, + ) + + if folders: + folder_ids = [folder.folder_id for folder in folders] + credential_counts = await app.DATABASE.credential_folders.get_credential_folder_credential_counts_batch( + folder_ids=folder_ids, + organization_id=current_org.organization_id, + ) + else: + credential_counts = {} + + return [_to_credential_folder_response(folder, credential_counts.get(folder.folder_id, 0)) for folder in folders] + + +@legacy_base_router.put("/credential_folders/{folder_id}") +@legacy_base_router.put("/credential_folders/{folder_id}/", include_in_schema=False) +@base_router.put( + "/credential_folders/{folder_id}", + response_model=CredentialFolder, + summary="Update credential folder", + description="Update a credential folder's title or description", + include_in_schema=False, + responses={ + 200: {"description": "Successfully updated credential folder"}, + 404: {"description": "Credential folder not found"}, + }, +) +@base_router.put("/credential_folders/{folder_id}/", response_model=CredentialFolder, include_in_schema=False) +async def update_credential_folder( + folder_id: str = Path(..., description="Credential folder ID", examples=["cfld_123"]), + data: CredentialFolderUpdate = Body(...), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CredentialFolder: + folder = await app.DATABASE.credential_folders.update_credential_folder( + folder_id=folder_id, + organization_id=current_org.organization_id, + title=data.title, + description=data.description, + ) + if not folder: + raise HTTPException(status_code=404, detail=f"Credential folder {folder_id} not found") + + credential_count = await app.DATABASE.credential_folders.get_credential_folder_credential_count( + folder_id=folder.folder_id, + organization_id=current_org.organization_id, + ) + return _to_credential_folder_response(folder, credential_count) + + +@legacy_base_router.delete("/credential_folders/{folder_id}") +@legacy_base_router.delete("/credential_folders/{folder_id}/", include_in_schema=False) +@base_router.delete( + "/credential_folders/{folder_id}", + summary="Delete credential folder", + description="Delete a credential folder. Credentials in the folder are detached (unfiled), not deleted.", + include_in_schema=False, + responses={ + 200: {"description": "Successfully deleted credential folder"}, + 404: {"description": "Credential folder not found"}, + }, +) +@base_router.delete("/credential_folders/{folder_id}/", include_in_schema=False) +async def delete_credential_folder( + folder_id: str = Path(..., description="Credential folder ID", examples=["cfld_123"]), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> dict: + success = await app.DATABASE.credential_folders.soft_delete_credential_folder( + folder_id=folder_id, + organization_id=current_org.organization_id, + ) + if not success: + raise HTTPException(status_code=404, detail=f"Credential folder {folder_id} not found") + + return {"status": "deleted", "folder_id": folder_id} + + +@legacy_base_router.put("/credentials/{credential_id}/folder") +@legacy_base_router.put("/credentials/{credential_id}/folder/", include_in_schema=False) +@base_router.put( + "/credentials/{credential_id}/folder", + response_model=CredentialResponse, + summary="Update credential folder assignment", + description="Assign a credential to a folder, or remove it from its folder when folder_id is null", + include_in_schema=False, + responses={ + 200: {"description": "Successfully updated credential folder assignment"}, + 404: {"description": "Credential not found"}, + 400: {"description": "Folder not found"}, + }, +) +@base_router.put("/credentials/{credential_id}/folder/", response_model=CredentialResponse, include_in_schema=False) +async def update_credential_folder_assignment( + credential_id: str = Path(..., description="The ID of the credential.", examples=["cred_1234567890"]), + data: UpdateCredentialFolderRequest = Body(...), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CredentialResponse: + try: + credential = await app.DATABASE.credential_folders.set_credential_folder( + credential_id=credential_id, + organization_id=current_org.organization_id, + folder_id=data.folder_id, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + if not credential: + raise HTTPException(status_code=404, detail=f"Credential {credential_id} not found") + + return _convert_to_response(credential) + + @base_router.get( "/credentials/onepassword/get", response_model=CreateOnePasswordTokenResponse, @@ -2028,6 +2247,7 @@ def _convert_to_response(credential: Credential) -> CredentialResponse: tested_url=credential.tested_url, user_context=credential.user_context, save_browser_session_intent=credential.save_browser_session_intent, + folder_id=credential.folder_id, ) elif credential.credential_type == CredentialType.CREDIT_CARD: credential_response = CreditCardCredentialResponse( @@ -2044,6 +2264,7 @@ def _convert_to_response(credential: Credential) -> CredentialResponse: tested_url=credential.tested_url, user_context=credential.user_context, save_browser_session_intent=credential.save_browser_session_intent, + folder_id=credential.folder_id, ) elif credential.credential_type == CredentialType.SECRET: credential_response = SecretCredentialResponse(secret_label=credential.secret_label) @@ -2057,6 +2278,7 @@ def _convert_to_response(credential: Credential) -> CredentialResponse: tested_url=credential.tested_url, user_context=credential.user_context, save_browser_session_intent=credential.save_browser_session_intent, + folder_id=credential.folder_id, ) else: raise HTTPException(status_code=400, detail="Credential type not supported") diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index f5209fe7b..2abea0d4a 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -195,6 +195,11 @@ class CredentialResponse(BaseModel): default=None, description="Whether the user intends to save a browser session, regardless of test outcome", ) + folder_id: str | None = Field( + default=None, + description="ID of the credential folder this credential belongs to, if any", + examples=["cfld_1234567890"], + ) class Credential(BaseModel): @@ -234,6 +239,10 @@ class Credential(BaseModel): default=False, description="Whether the user intends to save a browser session, regardless of test outcome", ) + folder_id: str | None = Field( + default=None, + description="ID of the credential folder this credential belongs to, if any", + ) created_at: datetime = Field(..., description="Timestamp when the credential was created") modified_at: datetime = Field(..., description="Timestamp when the credential was last modified") diff --git a/skyvern/schemas/credential_folders.py b/skyvern/schemas/credential_folders.py new file mode 100644 index 000000000..5fb9f547f --- /dev/null +++ b/skyvern/schemas/credential_folders.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class CredentialFolderBase(BaseModel): + title: str = Field(..., description="Folder title", min_length=1, max_length=255) + description: str | None = Field(None, description="Folder description") + + +class CredentialFolderCreate(CredentialFolderBase): + """Request model for creating a credential folder""" + + +class CredentialFolderUpdate(BaseModel): + """Request model for updating a credential folder""" + + title: str | None = Field(None, description="Folder title", min_length=1, max_length=255) + description: str | None = Field(None, description="Folder description") + + @model_validator(mode="after") + def validate_at_least_one_field(self) -> "CredentialFolderUpdate": + if self.title is None and self.description is None: + raise ValueError("at least one of 'title' or 'description' must be provided") + return self + + +class CredentialFolder(CredentialFolderBase): + """Response model for a credential folder""" + + model_config = ConfigDict(from_attributes=True) + + folder_id: str + organization_id: str + credential_count: int = Field(0, description="Number of credentials in this folder") + created_at: datetime + modified_at: datetime + + +class UpdateCredentialFolderRequest(BaseModel): + """Request model for updating a credential's folder assignment""" + + folder_id: str | None = Field( + ..., + description="Folder ID to assign credential to. Set explicitly to null to remove from folder.", + ) diff --git a/tests/unit/forge/sdk/db/test_credential_folders_repository.py b/tests/unit/forge/sdk/db/test_credential_folders_repository.py new file mode 100644 index 000000000..868b96e65 --- /dev/null +++ b/tests/unit/forge/sdk/db/test_credential_folders_repository.py @@ -0,0 +1,132 @@ +"""Behavioral coverage for CredentialFoldersRepository. + +Runs against an in-memory SQLite DB so the org-isolation and soft-delete +semantics are exercised for real — a regression that dropped an organization +filter or that soft-deleted members instead of detaching them would fail here +rather than slip through the instantiation smoke tests. +""" + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from skyvern.forge.sdk.db.models import Base, CredentialFolderModel, CredentialModel +from skyvern.forge.sdk.db.repositories.credential_folders import CredentialFoldersRepository + +ORG_A = "o_aaaaaaaaaaaaaaa" +ORG_B = "o_bbbbbbbbbbbbbbb" + + +@pytest_asyncio.fixture +async def repo_and_session() -> tuple: + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[CredentialFolderModel.__table__, CredentialModel.__table__], + ) + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + try: + yield CredentialFoldersRepository(session_factory), session_factory + finally: + await engine.dispose() + + +async def _add_credential(session_factory, organization_id: str, name: str = "cred") -> str: + async with session_factory() as session: + credential = CredentialModel( + organization_id=organization_id, + name=name, + credential_type="password", + vault_type="custom", + item_id="item", + username="user@example.com", + totp_type="none", + ) + session.add(credential) + await session.commit() + await session.refresh(credential) + return credential.credential_id + + +@pytest.mark.asyncio +async def test_set_credential_folder_rejects_other_orgs_credential(repo_and_session) -> None: + repo, session_factory = repo_and_session + folder = await repo.create_credential_folder(ORG_A, "Prod") + cred_b = await _add_credential(session_factory, ORG_B) + # ORG_A may not move a credential it doesn't own. + assert await repo.set_credential_folder(cred_b, ORG_A, folder.folder_id) is None + + +@pytest.mark.asyncio +async def test_set_credential_folder_rejects_other_orgs_folder(repo_and_session) -> None: + repo, session_factory = repo_and_session + folder_b = await repo.create_credential_folder(ORG_B, "OtherOrg") + cred_a = await _add_credential(session_factory, ORG_A) + # Assigning into a folder from another org is rejected, not silently applied. + with pytest.raises(ValueError): + await repo.set_credential_folder(cred_a, ORG_A, folder_b.folder_id) + + +@pytest.mark.asyncio +async def test_soft_delete_detaches_members_without_deleting_them(repo_and_session) -> None: + repo, session_factory = repo_and_session + folder = await repo.create_credential_folder(ORG_A, "Prod") + cred = await _add_credential(session_factory, ORG_A) + await repo.set_credential_folder(cred, ORG_A, folder.folder_id) + assert await repo.get_credential_folder_credential_count(folder.folder_id, ORG_A) == 1 + + assert await repo.soft_delete_credential_folder(folder.folder_id, ORG_A) is True + assert await repo.get_credential_folder(folder.folder_id, ORG_A) is None + + async with session_factory() as session: + refreshed = await session.get(CredentialModel, cred) + assert refreshed.folder_id is None # detached + assert refreshed.deleted_at is None # but NOT deleted (vault secret preserved) + + +@pytest.mark.asyncio +async def test_set_credential_folder_treats_empty_string_as_detach(repo_and_session) -> None: + repo, session_factory = repo_and_session + folder = await repo.create_credential_folder(ORG_A, "Prod") + cred = await _add_credential(session_factory, ORG_A) + await repo.set_credential_folder(cred, ORG_A, folder.folder_id) + assert await repo.get_credential_folder_credential_count(folder.folder_id, ORG_A) == 1 + + # A cleared