documentors 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- documentors/__init__.py +59 -0
- documentors/_transport.py +173 -0
- documentors/admin.py +250 -0
- documentors/client.py +98 -0
- documentors/compliance.py +137 -0
- documentors/documents.py +118 -0
- documentors/exceptions.py +55 -0
- documentors/models.py +224 -0
- documentors/security.py +132 -0
- documentors-0.1.0.dist-info/METADATA +145 -0
- documentors-0.1.0.dist-info/RECORD +13 -0
- documentors-0.1.0.dist-info/WHEEL +4 -0
- documentors-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Compliance operations — legal holds, eDiscovery, records management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from ._transport import Transport
|
|
9
|
+
from .models import LegalHold, RetentionPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Compliance:
|
|
13
|
+
"""``client.compliance.*`` — governance and compliance workflows."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, transport: Transport) -> None:
|
|
16
|
+
self._t = transport
|
|
17
|
+
|
|
18
|
+
# -- Legal Holds --------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def create_legal_hold(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
name: str,
|
|
24
|
+
description: Optional[str] = None,
|
|
25
|
+
custodian_ids: Optional[list[str]] = None,
|
|
26
|
+
) -> LegalHold:
|
|
27
|
+
"""Create a new legal hold."""
|
|
28
|
+
body: dict[str, Any] = {"name": name}
|
|
29
|
+
if description is not None:
|
|
30
|
+
body["description"] = description
|
|
31
|
+
if custodian_ids is not None:
|
|
32
|
+
body["custodian_ids"] = custodian_ids
|
|
33
|
+
resp = self._t.post("/api/v1/governance/holds", json=body)
|
|
34
|
+
return LegalHold.model_validate(resp.json())
|
|
35
|
+
|
|
36
|
+
def get_legal_hold(self, hold_id: UUID | str) -> LegalHold:
|
|
37
|
+
"""Get a legal hold by ID."""
|
|
38
|
+
resp = self._t.get(f"/api/v1/governance/holds/{hold_id}")
|
|
39
|
+
return LegalHold.model_validate(resp.json())
|
|
40
|
+
|
|
41
|
+
def list_legal_holds(self, *, status: Optional[str] = None) -> list[LegalHold]:
|
|
42
|
+
"""List all legal holds."""
|
|
43
|
+
resp = self._t.get("/api/v1/governance/holds", params={"status": status})
|
|
44
|
+
data = resp.json()
|
|
45
|
+
items = data if isinstance(data, list) else data.get("items", data.get("holds", []))
|
|
46
|
+
return [LegalHold.model_validate(h) for h in items]
|
|
47
|
+
|
|
48
|
+
def release_legal_hold(self, hold_id: UUID | str) -> LegalHold:
|
|
49
|
+
"""Release (close) a legal hold."""
|
|
50
|
+
resp = self._t.post(f"/api/v1/governance/holds/{hold_id}/release")
|
|
51
|
+
return LegalHold.model_validate(resp.json())
|
|
52
|
+
|
|
53
|
+
def add_custodian(self, hold_id: UUID | str, *, user_id: str) -> dict[str, Any]:
|
|
54
|
+
"""Add a custodian to a legal hold."""
|
|
55
|
+
resp = self._t.post(
|
|
56
|
+
f"/api/v1/governance/holds/{hold_id}/custodians",
|
|
57
|
+
json={"user_id": user_id},
|
|
58
|
+
)
|
|
59
|
+
return resp.json()
|
|
60
|
+
|
|
61
|
+
def place_document(self, hold_id: UUID | str, *, document_id: str) -> dict[str, Any]:
|
|
62
|
+
"""Place a document under legal hold (prevents deletion)."""
|
|
63
|
+
resp = self._t.post(
|
|
64
|
+
f"/api/v1/governance/holds/{hold_id}/documents",
|
|
65
|
+
json={"document_id": document_id},
|
|
66
|
+
)
|
|
67
|
+
return resp.json()
|
|
68
|
+
|
|
69
|
+
def get_custody_chain(self, hold_id: UUID | str) -> list[dict[str, Any]]:
|
|
70
|
+
"""Get HMAC-signed chain of custody logs."""
|
|
71
|
+
resp = self._t.get(f"/api/v1/governance/holds/{hold_id}/custody-chain")
|
|
72
|
+
data = resp.json()
|
|
73
|
+
return data if isinstance(data, list) else data.get("entries", [])
|
|
74
|
+
|
|
75
|
+
# -- eDiscovery ---------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def search(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
query: str,
|
|
81
|
+
date_from: Optional[str] = None,
|
|
82
|
+
date_to: Optional[str] = None,
|
|
83
|
+
custodians: Optional[list[str]] = None,
|
|
84
|
+
limit: int = 50,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Run an eDiscovery search (boolean / proximity)."""
|
|
87
|
+
body: dict[str, Any] = {"query": query, "limit": limit}
|
|
88
|
+
if date_from is not None:
|
|
89
|
+
body["date_from"] = date_from
|
|
90
|
+
if date_to is not None:
|
|
91
|
+
body["date_to"] = date_to
|
|
92
|
+
if custodians is not None:
|
|
93
|
+
body["custodians"] = custodians
|
|
94
|
+
resp = self._t.post("/api/v1/ediscovery/search", json=body)
|
|
95
|
+
return resp.json()
|
|
96
|
+
|
|
97
|
+
def create_review_set(self, *, name: str, search_id: Optional[str] = None) -> dict[str, Any]:
|
|
98
|
+
"""Create a review set for tagging and production."""
|
|
99
|
+
body: dict[str, Any] = {"name": name}
|
|
100
|
+
if search_id is not None:
|
|
101
|
+
body["search_id"] = search_id
|
|
102
|
+
resp = self._t.post("/api/v1/ediscovery/review-sets", json=body)
|
|
103
|
+
return resp.json()
|
|
104
|
+
|
|
105
|
+
# -- Records Management -------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def list_retention_policies(self) -> list[RetentionPolicy]:
|
|
108
|
+
"""List all retention policies."""
|
|
109
|
+
resp = self._t.get("/api/v1/governance/retention/policies")
|
|
110
|
+
data = resp.json()
|
|
111
|
+
items = data if isinstance(data, list) else data.get("items", data.get("policies", []))
|
|
112
|
+
return [RetentionPolicy.model_validate(p) for p in items]
|
|
113
|
+
|
|
114
|
+
def create_retention_policy(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
name: str,
|
|
118
|
+
retention_days: int,
|
|
119
|
+
template: Optional[str] = None,
|
|
120
|
+
) -> RetentionPolicy:
|
|
121
|
+
"""Create a retention policy.
|
|
122
|
+
|
|
123
|
+
``template`` can be ``HIPAA_6YR``, ``SOX_7YR``, ``GDPR_3YR``, ``IRS_7YR``.
|
|
124
|
+
"""
|
|
125
|
+
body: dict[str, Any] = {"name": name, "retention_days": retention_days}
|
|
126
|
+
if template is not None:
|
|
127
|
+
body["template"] = template
|
|
128
|
+
resp = self._t.post("/api/v1/governance/retention/policies", json=body)
|
|
129
|
+
return RetentionPolicy.model_validate(resp.json())
|
|
130
|
+
|
|
131
|
+
def declare_record(self, document_id: UUID | str, *, policy_id: str) -> dict[str, Any]:
|
|
132
|
+
"""Declare a document as a record under a retention policy."""
|
|
133
|
+
resp = self._t.post(
|
|
134
|
+
f"/api/v1/records/declare",
|
|
135
|
+
json={"policy_id": policy_id},
|
|
136
|
+
)
|
|
137
|
+
return resp.json()
|
documentors/documents.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Document operations — upload, list, get, delete, versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, BinaryIO, Optional
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from ._transport import Transport
|
|
10
|
+
from .models import Document, DocumentVersion, PaginatedResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Documents:
|
|
14
|
+
"""``client.documents.*`` — CRUD and version management."""
|
|
15
|
+
|
|
16
|
+
_PREFIX = "/api/v1/documents"
|
|
17
|
+
|
|
18
|
+
def __init__(self, transport: Transport) -> None:
|
|
19
|
+
self._t = transport
|
|
20
|
+
|
|
21
|
+
def list(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
status: Optional[str] = None,
|
|
25
|
+
search: Optional[str] = None,
|
|
26
|
+
skip: int = 0,
|
|
27
|
+
limit: int = 50,
|
|
28
|
+
) -> PaginatedResponse:
|
|
29
|
+
"""List documents with optional filters."""
|
|
30
|
+
resp = self._t.get(
|
|
31
|
+
self._PREFIX,
|
|
32
|
+
params={"status": status, "search": search, "skip": skip, "limit": limit},
|
|
33
|
+
)
|
|
34
|
+
return PaginatedResponse.model_validate(resp.json())
|
|
35
|
+
|
|
36
|
+
def get(self, document_id: UUID | str) -> Document:
|
|
37
|
+
"""Retrieve a single document by ID."""
|
|
38
|
+
resp = self._t.get(f"{self._PREFIX}/{document_id}")
|
|
39
|
+
return Document.model_validate(resp.json())
|
|
40
|
+
|
|
41
|
+
def upload(
|
|
42
|
+
self,
|
|
43
|
+
file: str | Path | BinaryIO,
|
|
44
|
+
*,
|
|
45
|
+
title: Optional[str] = None,
|
|
46
|
+
classification: Optional[str] = None,
|
|
47
|
+
project_id: Optional[str] = None,
|
|
48
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
49
|
+
) -> Document:
|
|
50
|
+
"""Upload a new document.
|
|
51
|
+
|
|
52
|
+
``file`` may be a file path, or an open binary file object.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(file, (str, Path)):
|
|
55
|
+
path = Path(file)
|
|
56
|
+
with open(path, "rb") as fh:
|
|
57
|
+
return self._do_upload(
|
|
58
|
+
fh,
|
|
59
|
+
filename=path.name,
|
|
60
|
+
title=title,
|
|
61
|
+
classification=classification,
|
|
62
|
+
project_id=project_id,
|
|
63
|
+
metadata=metadata,
|
|
64
|
+
)
|
|
65
|
+
return self._do_upload(
|
|
66
|
+
file,
|
|
67
|
+
filename=getattr(file, "name", "upload"),
|
|
68
|
+
title=title,
|
|
69
|
+
classification=classification,
|
|
70
|
+
project_id=project_id,
|
|
71
|
+
metadata=metadata,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def delete(self, document_id: UUID | str) -> None:
|
|
75
|
+
"""Delete a document by ID."""
|
|
76
|
+
self._t.delete(f"{self._PREFIX}/{document_id}")
|
|
77
|
+
|
|
78
|
+
def versions(self, document_id: UUID | str) -> list[DocumentVersion]:
|
|
79
|
+
"""List version history for a document."""
|
|
80
|
+
resp = self._t.get(f"{self._PREFIX}/{document_id}/versions")
|
|
81
|
+
data = resp.json()
|
|
82
|
+
items = data if isinstance(data, list) else data.get("items", data.get("versions", []))
|
|
83
|
+
return [DocumentVersion.model_validate(v) for v in items]
|
|
84
|
+
|
|
85
|
+
def download(self, document_id: UUID | str) -> bytes:
|
|
86
|
+
"""Download the raw content of a document."""
|
|
87
|
+
resp = self._t.get(f"{self._PREFIX}/{document_id}/download")
|
|
88
|
+
return resp.content
|
|
89
|
+
|
|
90
|
+
# -- internal -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _do_upload(
|
|
93
|
+
self,
|
|
94
|
+
fh: BinaryIO,
|
|
95
|
+
*,
|
|
96
|
+
filename: str,
|
|
97
|
+
title: Optional[str],
|
|
98
|
+
classification: Optional[str],
|
|
99
|
+
project_id: Optional[str],
|
|
100
|
+
metadata: Optional[dict[str, Any]],
|
|
101
|
+
) -> Document:
|
|
102
|
+
form: dict[str, Any] = {}
|
|
103
|
+
if title is not None:
|
|
104
|
+
form["title"] = title
|
|
105
|
+
if classification is not None:
|
|
106
|
+
form["classification"] = classification
|
|
107
|
+
if project_id is not None:
|
|
108
|
+
form["project_id"] = project_id
|
|
109
|
+
if metadata is not None:
|
|
110
|
+
import json
|
|
111
|
+
form["metadata"] = json.dumps(metadata)
|
|
112
|
+
|
|
113
|
+
resp = self._t.post(
|
|
114
|
+
f"{self._PREFIX}/upload",
|
|
115
|
+
files={"file": (filename, fh)},
|
|
116
|
+
data=form,
|
|
117
|
+
)
|
|
118
|
+
return Document.model_validate(resp.json())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Exceptions raised by the DocumentorsClient SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DocumentorsError(Exception):
|
|
9
|
+
"""Base exception for all SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, details: Optional[dict[str, Any]] = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.details = details or {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(DocumentorsError):
|
|
17
|
+
"""Raised when authentication fails (401)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionDeniedError(DocumentorsError):
|
|
21
|
+
"""Raised when the caller lacks required scope or role (403)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotFoundError(DocumentorsError):
|
|
25
|
+
"""Raised when the requested resource does not exist (404)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(DocumentorsError):
|
|
29
|
+
"""Raised when request validation fails (422)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimitError(DocumentorsError):
|
|
33
|
+
"""Raised when rate limit is exceeded (429).
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
retry_after: Seconds until the caller may retry.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
message: str = "Rate limit exceeded",
|
|
42
|
+
*,
|
|
43
|
+
retry_after: Optional[int] = None,
|
|
44
|
+
details: Optional[dict[str, Any]] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message, details=details)
|
|
47
|
+
self.retry_after = retry_after
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServerError(DocumentorsError):
|
|
51
|
+
"""Raised on 5xx responses from the platform."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictError(DocumentorsError):
|
|
55
|
+
"""Raised when the operation conflicts with current state (409)."""
|
documentors/models.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Pydantic response models for the DocuMentors API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Pagination envelope
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
class PaginatedResponse(BaseModel):
|
|
17
|
+
"""Wrapper returned by paginated list endpoints."""
|
|
18
|
+
|
|
19
|
+
items: list[dict[str, Any]] = Field(default_factory=list)
|
|
20
|
+
total: int = 0
|
|
21
|
+
skip: int = 0
|
|
22
|
+
limit: int = 50
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Auth
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
class TokenUser(BaseModel):
|
|
30
|
+
email: str
|
|
31
|
+
full_name: Optional[str] = None
|
|
32
|
+
is_superuser: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TokenResponse(BaseModel):
|
|
36
|
+
access_token: str
|
|
37
|
+
token_type: str = "bearer"
|
|
38
|
+
expires_in: int = 900
|
|
39
|
+
user: Optional[TokenUser] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Documents
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
class Document(BaseModel):
|
|
47
|
+
id: UUID
|
|
48
|
+
title: Optional[str] = None
|
|
49
|
+
filename: Optional[str] = None
|
|
50
|
+
status: Optional[str] = None
|
|
51
|
+
classification: Optional[str] = None
|
|
52
|
+
organization_id: Optional[UUID] = None
|
|
53
|
+
created_at: Optional[datetime] = None
|
|
54
|
+
updated_at: Optional[datetime] = None
|
|
55
|
+
file_size: Optional[int] = None
|
|
56
|
+
content_type: Optional[str] = None
|
|
57
|
+
version: Optional[int] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DocumentVersion(BaseModel):
|
|
61
|
+
id: UUID
|
|
62
|
+
document_id: UUID
|
|
63
|
+
version_number: int
|
|
64
|
+
created_at: Optional[datetime] = None
|
|
65
|
+
created_by: Optional[str] = None
|
|
66
|
+
change_summary: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# PII / Security
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
class PIIFinding(BaseModel):
|
|
74
|
+
type: str
|
|
75
|
+
confidence: float
|
|
76
|
+
start_offset: Optional[int] = None
|
|
77
|
+
end_offset: Optional[int] = None
|
|
78
|
+
masked_value: Optional[str] = None
|
|
79
|
+
risk_level: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ScanResult(BaseModel):
|
|
83
|
+
scan_id: UUID
|
|
84
|
+
document_id: UUID
|
|
85
|
+
status: str = "completed"
|
|
86
|
+
findings: list[PIIFinding] = Field(default_factory=list)
|
|
87
|
+
entity_count: Optional[int] = None
|
|
88
|
+
scanned_at: Optional[datetime] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ScanHistoryEntry(BaseModel):
|
|
92
|
+
scan_id: UUID
|
|
93
|
+
document_id: UUID
|
|
94
|
+
status: str
|
|
95
|
+
entity_count: Optional[int] = None
|
|
96
|
+
scanned_at: Optional[datetime] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PhishingVerdict(BaseModel):
|
|
100
|
+
is_phishing: bool
|
|
101
|
+
confidence: float
|
|
102
|
+
verdict: str = "legitimate"
|
|
103
|
+
signals: Optional[dict[str, float]] = None
|
|
104
|
+
threat_indicators: list[str] = Field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class URLThreat(BaseModel):
|
|
108
|
+
url: str
|
|
109
|
+
is_threat: bool
|
|
110
|
+
confidence: float = 0.0
|
|
111
|
+
threat_type: Optional[str] = None
|
|
112
|
+
source: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Governance / Compliance
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
class LegalHold(BaseModel):
|
|
120
|
+
id: UUID
|
|
121
|
+
name: str
|
|
122
|
+
status: Optional[str] = None
|
|
123
|
+
description: Optional[str] = None
|
|
124
|
+
created_at: Optional[datetime] = None
|
|
125
|
+
created_by: Optional[str] = None
|
|
126
|
+
released_at: Optional[datetime] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RetentionPolicy(BaseModel):
|
|
130
|
+
id: UUID
|
|
131
|
+
name: str
|
|
132
|
+
retention_days: int
|
|
133
|
+
template: Optional[str] = None
|
|
134
|
+
status: Optional[str] = None
|
|
135
|
+
created_at: Optional[datetime] = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Admin / Users
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class User(BaseModel):
|
|
143
|
+
id: UUID
|
|
144
|
+
email: str
|
|
145
|
+
full_name: Optional[str] = None
|
|
146
|
+
is_active: bool = True
|
|
147
|
+
is_superuser: bool = False
|
|
148
|
+
organization_id: Optional[UUID] = None
|
|
149
|
+
org_role: Optional[str] = None
|
|
150
|
+
created_at: Optional[datetime] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class APIKey(BaseModel):
|
|
154
|
+
id: UUID
|
|
155
|
+
api_key_prefix: str
|
|
156
|
+
name: str
|
|
157
|
+
is_active: bool = True
|
|
158
|
+
created_at: Optional[datetime] = None
|
|
159
|
+
last_used_at: Optional[datetime] = None
|
|
160
|
+
expires_at: Optional[datetime] = None
|
|
161
|
+
rate_limit_override: Optional[int] = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class APIKeyCreated(APIKey):
|
|
165
|
+
"""Returned only on key creation — contains the one-time secret."""
|
|
166
|
+
|
|
167
|
+
api_key_secret: str
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Devices
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
class Device(BaseModel):
|
|
175
|
+
id: UUID
|
|
176
|
+
device_name: str
|
|
177
|
+
status: Optional[str] = None
|
|
178
|
+
activated_at: Optional[datetime] = None
|
|
179
|
+
expires_at: Optional[datetime] = None
|
|
180
|
+
last_seen_at: Optional[datetime] = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ActivationCode(BaseModel):
|
|
184
|
+
id: UUID
|
|
185
|
+
code: Optional[str] = None
|
|
186
|
+
max_uses: int = 1
|
|
187
|
+
uses: int = 0
|
|
188
|
+
expires_at: Optional[datetime] = None
|
|
189
|
+
created_at: Optional[datetime] = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Org Members
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
class OrgMember(BaseModel):
|
|
197
|
+
id: int
|
|
198
|
+
organization_id: UUID
|
|
199
|
+
user_id: int
|
|
200
|
+
role: str
|
|
201
|
+
department_id: Optional[UUID] = None
|
|
202
|
+
is_active: bool = True
|
|
203
|
+
joined_at: Optional[datetime] = None
|
|
204
|
+
last_active_at: Optional[datetime] = None
|
|
205
|
+
user_email: str = ""
|
|
206
|
+
user_username: str = ""
|
|
207
|
+
user_full_name: Optional[str] = None
|
|
208
|
+
department_name: Optional[str] = None
|
|
209
|
+
documents_count: int = 0
|
|
210
|
+
projects_count: int = 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class OrgInvitation(BaseModel):
|
|
214
|
+
id: int
|
|
215
|
+
organization_id: UUID
|
|
216
|
+
organization_name: str = ""
|
|
217
|
+
email: str
|
|
218
|
+
role: str = "member"
|
|
219
|
+
department_id: Optional[UUID] = None
|
|
220
|
+
invited_by_id: int
|
|
221
|
+
invited_by_name: str = ""
|
|
222
|
+
status: str # pending, accepted, declined, expired
|
|
223
|
+
invited_at: Optional[datetime] = None
|
|
224
|
+
expires_at: Optional[datetime] = None
|
documentors/security.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Security operations — PII detection, phishing analysis, redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from ._transport import Transport
|
|
9
|
+
from .models import PhishingVerdict, PIIFinding, ScanHistoryEntry, ScanResult, URLThreat
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Security:
|
|
13
|
+
"""``client.security.*`` — PII scanning, phishing detection, redaction."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, transport: Transport) -> None:
|
|
16
|
+
self._t = transport
|
|
17
|
+
|
|
18
|
+
# -- PII scanning -------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def detect_pii(
|
|
21
|
+
self,
|
|
22
|
+
document_id: UUID | str,
|
|
23
|
+
*,
|
|
24
|
+
min_confidence: float = 0.70,
|
|
25
|
+
) -> ScanResult:
|
|
26
|
+
"""Trigger a PII scan on a document. Returns findings."""
|
|
27
|
+
resp = self._t.post(
|
|
28
|
+
f"/api/v1/documents/{document_id}/scan",
|
|
29
|
+
json={"min_confidence": min_confidence},
|
|
30
|
+
)
|
|
31
|
+
return ScanResult.model_validate(resp.json())
|
|
32
|
+
|
|
33
|
+
def get_scan_results(
|
|
34
|
+
self,
|
|
35
|
+
document_id: UUID | str,
|
|
36
|
+
*,
|
|
37
|
+
scan_id: Optional[UUID | str] = None,
|
|
38
|
+
) -> ScanResult:
|
|
39
|
+
"""Retrieve results for a specific (or latest) scan."""
|
|
40
|
+
params: dict[str, Any] = {}
|
|
41
|
+
if scan_id is not None:
|
|
42
|
+
params["scan_id"] = str(scan_id)
|
|
43
|
+
resp = self._t.get(f"/api/v1/documents/{document_id}/scan-results", params=params)
|
|
44
|
+
return ScanResult.model_validate(resp.json())
|
|
45
|
+
|
|
46
|
+
def scan_history(self, document_id: UUID | str) -> list[ScanHistoryEntry]:
|
|
47
|
+
"""List past scans for a document."""
|
|
48
|
+
resp = self._t.get(f"/api/v1/documents/{document_id}/scan-history")
|
|
49
|
+
data = resp.json()
|
|
50
|
+
items = data if isinstance(data, list) else data.get("items", [])
|
|
51
|
+
return [ScanHistoryEntry.model_validate(e) for e in items]
|
|
52
|
+
|
|
53
|
+
def redact_pii(
|
|
54
|
+
self,
|
|
55
|
+
document_id: UUID | str,
|
|
56
|
+
*,
|
|
57
|
+
strategy: str = "MASK",
|
|
58
|
+
entity_types: Optional[list[str]] = None,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Apply redaction to detected PII entities.
|
|
61
|
+
|
|
62
|
+
``strategy`` can be ``MASK``, ``REPLACE``, or ``REMOVE``.
|
|
63
|
+
"""
|
|
64
|
+
body: dict[str, Any] = {"strategy": strategy}
|
|
65
|
+
if entity_types is not None:
|
|
66
|
+
body["entity_types"] = entity_types
|
|
67
|
+
resp = self._t.post(f"/api/v1/documents/{document_id}/redact", json=body)
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
# -- Phishing detection -------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def analyze_email(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
email_content: str,
|
|
76
|
+
sender: str,
|
|
77
|
+
recipient: str,
|
|
78
|
+
subject: str,
|
|
79
|
+
headers: Optional[dict[str, str]] = None,
|
|
80
|
+
) -> PhishingVerdict:
|
|
81
|
+
"""Analyze a single email for phishing indicators."""
|
|
82
|
+
body: dict[str, Any] = {
|
|
83
|
+
"email_content": email_content,
|
|
84
|
+
"sender": sender,
|
|
85
|
+
"recipient": recipient,
|
|
86
|
+
"subject": subject,
|
|
87
|
+
}
|
|
88
|
+
if headers is not None:
|
|
89
|
+
body["headers"] = headers
|
|
90
|
+
resp = self._t.post("/api/v1/phishing/analyze/email", json=body)
|
|
91
|
+
return PhishingVerdict.model_validate(resp.json())
|
|
92
|
+
|
|
93
|
+
def analyze_emails(
|
|
94
|
+
self,
|
|
95
|
+
emails: list[dict[str, Any]],
|
|
96
|
+
) -> list[PhishingVerdict]:
|
|
97
|
+
"""Batch-analyze up to 50 emails."""
|
|
98
|
+
resp = self._t.post("/api/v1/phishing/analyze/email/batch", json={"emails": emails})
|
|
99
|
+
data = resp.json()
|
|
100
|
+
items = data if isinstance(data, list) else data.get("results", [])
|
|
101
|
+
return [PhishingVerdict.model_validate(v) for v in items]
|
|
102
|
+
|
|
103
|
+
def analyze_url(self, url: str) -> URLThreat:
|
|
104
|
+
"""Check a single URL against threat intelligence."""
|
|
105
|
+
resp = self._t.post("/api/v1/phishing/analyze/url", json={"url": url})
|
|
106
|
+
return URLThreat.model_validate(resp.json())
|
|
107
|
+
|
|
108
|
+
def analyze_urls(self, urls: list[str]) -> list[URLThreat]:
|
|
109
|
+
"""Batch-check up to 200 URLs."""
|
|
110
|
+
resp = self._t.post("/api/v1/phishing/analyze/urls", json={"urls": urls})
|
|
111
|
+
data = resp.json()
|
|
112
|
+
items = data if isinstance(data, list) else data.get("results", [])
|
|
113
|
+
return [URLThreat.model_validate(t) for t in items]
|
|
114
|
+
|
|
115
|
+
def check_domain(self, domain: str) -> dict[str, Any]:
|
|
116
|
+
"""Get domain reputation information."""
|
|
117
|
+
resp = self._t.get("/api/v1/phishing/analyze/domain", params={"domain": domain})
|
|
118
|
+
return resp.json()
|
|
119
|
+
|
|
120
|
+
def submit_feedback(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
verdict_id: str,
|
|
124
|
+
is_correct: bool,
|
|
125
|
+
notes: Optional[str] = None,
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
"""Submit false-positive / false-negative feedback."""
|
|
128
|
+
body: dict[str, Any] = {"verdict_id": verdict_id, "is_correct": is_correct}
|
|
129
|
+
if notes is not None:
|
|
130
|
+
body["notes"] = notes
|
|
131
|
+
resp = self._t.post("/api/v1/phishing/feedback", json=body)
|
|
132
|
+
return resp.json()
|