T11: tags plugin + permissions plugin + entity links backend — 68 tests, 66.61% coverage

This commit is contained in:
leocrm-bot
2026-06-29 14:01:24 +02:00
parent 700b7a71ad
commit 5d1850768a
26 changed files with 2863 additions and 7 deletions
@@ -0,0 +1,5 @@
"""Permissions builtin plugin."""
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
__all__ = ["PermissionsPlugin"]
@@ -0,0 +1,29 @@
-- Permissions plugin initial migration: creates permissions and share_links tables
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
file_id UUID NOT NULL,
user_id UUID NOT NULL,
group_id UUID,
access_level VARCHAR(10) NOT NULL DEFAULT 'read',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_permissions_file_user_level UNIQUE (tenant_id, file_id, user_id, access_level)
);
CREATE INDEX IF NOT EXISTS ix_permissions_file ON permissions(tenant_id, file_id);
CREATE INDEX IF NOT EXISTS ix_permissions_user ON permissions(tenant_id, user_id);
CREATE TABLE IF NOT EXISTS share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
file_id UUID NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255),
expires_at TIMESTAMPTZ,
access_level VARCHAR(10) NOT NULL DEFAULT 'download',
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_share_links_token ON share_links(token);
CREATE INDEX IF NOT EXISTS ix_share_links_file ON share_links(tenant_id, file_id);
@@ -0,0 +1,60 @@
"""Permission and ShareLink models for the Permissions plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Permission(Base, TenantMixin):
"""File/folder permission — grants access_level to a user or group."""
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "user_id", "access_level",
name="uq_permissions_file_user_level",
),
Index("ix_permissions_file", "tenant_id", "file_id"),
Index("ix_permissions_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read")
class ShareLink(Base, TenantMixin):
"""Public share link for a file — optional password and expiry."""
__tablename__ = "share_links"
__table_args__ = (
Index("ix_share_links_token", "token", unique=True),
Index("ix_share_links_file", "tenant_id", "file_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="download")
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
@@ -0,0 +1,37 @@
"""Permissions plugin — file/folder permissions, share links, public access."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class PermissionsPlugin(BasePlugin):
"""Permissions plugin for managing file/folder access and public share links."""
manifest = PluginManifest(
name="permissions",
version="1.0.0",
display_name="Permissions",
description="File/folder permissions, share links with password+expiry, public access.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/dms",
module="app.plugins.builtins.permissions.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/public",
module="app.plugins.builtins.permissions.routes",
router_attr="public_router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[],
)
+294
View File
@@ -0,0 +1,294 @@
"""Permissions plugin routes — file/folder permissions, share links, public access."""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password, verify_password
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.permissions.models import Permission, ShareLink
from app.plugins.builtins.permissions.schemas import (
PermissionGrantRequest,
ShareLinkCreateRequest,
ShareLinkVerifyRequest,
)
router = APIRouter(prefix="/api/v1/dms", tags=["permissions"])
public_router = APIRouter(prefix="/api/public", tags=["public-share"])
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
# ─── Permissions CRUD ───
@router.get("/files/{file_id}/permissions")
async def list_permissions(
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all permissions for a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
)
)
perms = result.scalars().all()
return [
{
"id": str(p.id),
"file_id": str(p.file_id),
"user_id": str(p.user_id),
"group_id": str(p.group_id) if p.group_id else None,
"access_level": p.access_level,
}
for p in perms
]
@router.post("/files/{file_id}/permissions", status_code=status.HTTP_201_CREATED)
async def grant_permission(
file_id: str,
body: PermissionGrantRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Grant a permission on a file to a user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
user_id = _parse_uuid(body.user_id, "user_id")
group_id = _parse_uuid(body.group_id, "group_id") if body.group_id else None
# Check if permission already exists
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == user_id,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Permission already exists", "code": "duplicate"})
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=user_id,
group_id=group_id,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
return {
"id": str(perm.id),
"file_id": str(perm.file_id),
"user_id": str(perm.user_id),
"group_id": str(perm.group_id) if perm.group_id else None,
"access_level": perm.access_level,
}
@router.delete("/files/{file_id}/permissions/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_permission(
file_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Revoke all permissions for a user on a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
uid = _parse_uuid(user_id, "user_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
)
)
perms = result.scalars().all()
if not perms:
raise HTTPException(404, detail={"detail": "Permission not found", "code": "not_found"})
for p in perms:
await db.delete(p)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Permission Check Helper ───
async def check_user_file_permission(
db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read"
) -> bool:
"""Check if user has required permission on a file."""
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == file_id,
Permission.user_id == user_id,
)
)
perms = result.scalars().all()
if not perms:
return False
if required == "read":
return any(p.access_level in ("read", "write") for p in perms)
if required == "write":
return any(p.access_level == "write" for p in perms)
return False
# ─── Share Links ───
@router.post("/files/{file_id}/share-link")
async def create_share_link(
file_id: str,
body: ShareLinkCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a public share link for a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id")
token = secrets.token_urlsafe(32)
password_hash = hash_password(body.password) if body.password else None
link = ShareLink(
tenant_id=tenant_id,
file_id=fid,
token=token,
password_hash=password_hash,
expires_at=body.expires_at,
access_level=body.access_level,
created_by=user_id,
)
db.add(link)
await db.flush()
return {
"id": str(link.id),
"file_id": str(link.file_id),
"token": token,
"public_url": f"/api/public/share/{token}",
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
"access_level": link.access_level,
"has_password": password_hash is not None,
}
@router.delete("/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_share_link(
link_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Revoke (delete) a share link."""
tenant_id = uuid.UUID(current_user["tenant_id"])
lid = _parse_uuid(link_id, "link_id")
result = await db.execute(
select(ShareLink).where(ShareLink.id == lid, ShareLink.tenant_id == tenant_id)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
await db.delete(link)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Public Share Access (NO AUTH) ───
@public_router.get("/share/{token}")
async def public_access(
token: str,
db: AsyncSession = Depends(get_db),
):
"""Public share access — no auth required.
Returns 410 Gone if link is expired.
Returns 403 if password is required but not provided/invalid.
"""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Check password
if link.password_hash is not None:
# Password must be provided via query param or header — check query param
from fastapi import Request
# We need the request to get the password param
# For simplicity, return that password is required
raise HTTPException(
401,
detail={"detail": "Password required", "code": "password_required"},
)
return {
"file_id": str(link.file_id),
"access_level": link.access_level,
"has_password": False,
}
@public_router.post("/share/{token}")
async def public_access_with_password(
token: str,
body: ShareLinkVerifyRequest,
db: AsyncSession = Depends(get_db),
):
"""Public share access with password verification — no auth required."""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Verify password if required
if link.password_hash is not None:
if body.password is None:
raise HTTPException(401, detail={"detail": "Password required", "code": "password_required"})
if not verify_password(body.password, link.password_hash):
raise HTTPException(403, detail={"detail": "Invalid password", "code": "invalid_password"})
return {
"file_id": str(link.file_id),
"access_level": link.access_level,
"has_password": link.password_hash is not None,
}
@@ -0,0 +1,41 @@
"""Pydantic schemas for the Permissions plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PermissionGrantRequest(BaseModel):
user_id: str
group_id: str | None = None
access_level: str = Field("read", pattern="^(read|write)$")
class PermissionResponse(BaseModel):
id: str
file_id: str
user_id: str
group_id: str | None = None
access_level: str
class ShareLinkCreateRequest(BaseModel):
password: str | None = Field(None, min_length=4, max_length=100)
expires_at: datetime | None = None
access_level: str = Field("download", pattern="^(download|preview)$")
class ShareLinkResponse(BaseModel):
id: str
file_id: str
token: str
public_url: str
expires_at: datetime | None = None
access_level: str
has_password: bool
class ShareLinkVerifyRequest(BaseModel):
password: str | None = None