diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0a37b..33e7962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-04-06 + +### Added +- **Multi-tenancy**: `tenant_id` parameter on `add()`, `list()`, `search()`, and all public methods +- `tenant_id` column in SQLite and PostgreSQL storage schemas with index +- Tenant-scoped search: queries filter by `tenant_id` when provided +- `vault.create_collection()` and `vault.list_collections()` — Collection CRUD +- Auto-detection of qp-capsule: if installed, `CapsuleAuditor` is used automatically (no manual wiring) + ## [0.6.0] - 2026-04-06 ### Added diff --git a/pyproject.toml b/pyproject.toml index f59f56b..e12fb62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "qp-vault" -version = "0.6.0" +version = "0.7.0" description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native." readme = "README.md" license = "Apache-2.0" diff --git a/src/qp_vault/__init__.py b/src/qp_vault/__init__.py index 39eecd7..0dc91e8 100644 --- a/src/qp_vault/__init__.py +++ b/src/qp_vault/__init__.py @@ -26,7 +26,7 @@ Docs: https://github.com/quantumpipes/vault """ -__version__ = "0.6.0" +__version__ = "0.7.0" __author__ = "Quantum Pipes Technologies, LLC" __license__ = "Apache-2.0" diff --git a/src/qp_vault/core/resource_manager.py b/src/qp_vault/core/resource_manager.py index e308828..15786ba 100644 --- a/src/qp_vault/core/resource_manager.py +++ b/src/qp_vault/core/resource_manager.py @@ -103,6 +103,7 @@ async def add( lifecycle: Lifecycle | str = Lifecycle.ACTIVE, valid_from: date | None = None, valid_until: date | None = None, + tenant_id: str | None = None, ) -> Resource: """Add a resource from text content. @@ -163,6 +164,7 @@ async def add( lifecycle=lc, valid_from=valid_from, valid_until=valid_until, + tenant_id=tenant_id, collection_id=collection, layer=mem_layer, tags=tags or [], @@ -204,6 +206,7 @@ async def get(self, resource_id: str) -> Resource: async def list( self, *, + tenant_id: str | None = None, trust: TrustTier | str | None = None, classification: DataClassification | str | None = None, layer: MemoryLayer | str | None = None, @@ -216,6 +219,7 @@ async def list( ) -> list[Resource]: """List resources with filters.""" filters = ResourceFilter( + tenant_id=tenant_id, trust_tier=trust.value if hasattr(trust, "value") else trust, data_classification=classification.value if hasattr(classification, "value") else classification, layer=layer.value if hasattr(layer, "value") else layer, diff --git a/src/qp_vault/models.py b/src/qp_vault/models.py index 59df167..d318968 100644 --- a/src/qp_vault/models.py +++ b/src/qp_vault/models.py @@ -49,6 +49,9 @@ class Resource(BaseModel): supersedes: str | None = None superseded_by: str | None = None + # Multi-tenancy + tenant_id: str | None = None + # Organization collection_id: str | None = None layer: MemoryLayer | None = None diff --git a/src/qp_vault/protocols.py b/src/qp_vault/protocols.py index 75262a3..26f2649 100644 --- a/src/qp_vault/protocols.py +++ b/src/qp_vault/protocols.py @@ -19,6 +19,7 @@ class ResourceFilter: """Filtering criteria for resource listing.""" + tenant_id: str | None = None trust_tier: str | None = None data_classification: str | None = None resource_type: str | None = None diff --git a/src/qp_vault/storage/sqlite.py b/src/qp_vault/storage/sqlite.py index f67f608..5c9e83e 100644 --- a/src/qp_vault/storage/sqlite.py +++ b/src/qp_vault/storage/sqlite.py @@ -38,6 +38,7 @@ valid_until TEXT, supersedes TEXT, superseded_by TEXT, + tenant_id TEXT, collection_id TEXT, layer TEXT, tags TEXT DEFAULT '[]', @@ -111,6 +112,7 @@ CREATE INDEX IF NOT EXISTS idx_chunks_cid ON chunks(cid); CREATE INDEX IF NOT EXISTS idx_provenance_resource ON provenance(resource_id); CREATE INDEX IF NOT EXISTS idx_resources_adversarial ON resources(adversarial_status); +CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id); """ _FTS_SCHEMA = """ @@ -212,14 +214,14 @@ async def store_resource(self, resource: Resource) -> str: id, name, content_hash, cid, merkle_root, trust_tier, data_classification, resource_type, status, lifecycle, adversarial_status, valid_from, valid_until, - supersedes, superseded_by, collection_id, layer, + supersedes, superseded_by, tenant_id, collection_id, layer, tags, metadata, mime_type, size_bytes, chunk_count, created_at, updated_at, indexed_at, deleted_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )""", @@ -239,6 +241,7 @@ async def store_resource(self, resource: Resource) -> str: str(resource.valid_until) if resource.valid_until else None, resource.supersedes, resource.superseded_by, + resource.tenant_id, resource.collection_id, resource.layer.value if resource.layer and hasattr(resource.layer, "value") else resource.layer, json.dumps(resource.tags), @@ -271,6 +274,9 @@ async def list_resources(self, filters: ResourceFilter) -> list[Resource]: conditions: list[str] = [] params: list[Any] = [] + if filters.tenant_id: + conditions.append("tenant_id = ?") + params.append(filters.tenant_id) if filters.trust_tier: conditions.append("trust_tier = ?") params.append(filters.trust_tier) @@ -398,10 +404,14 @@ async def search(self, query: SearchQuery) -> list[SearchResult]: where_parts = ["r.status = 'indexed'"] where_params: list[Any] = [] + filter_tenant = query.filters.tenant_id if query.filters else None filter_trust = query.filters.trust_tier if query.filters else None filter_layer = query.filters.layer if query.filters else None filter_collection = query.filters.collection_id if query.filters else None + if filter_tenant: + where_parts.append("r.tenant_id = ?") + where_params.append(filter_tenant) if filter_trust: where_parts.append("r.trust_tier = ?") where_params.append(filter_trust) diff --git a/src/qp_vault/vault.py b/src/qp_vault/vault.py index 153cab9..b63f445 100644 --- a/src/qp_vault/vault.py +++ b/src/qp_vault/vault.py @@ -154,11 +154,18 @@ def __init__( # Embedding provider self._embedder = embedder - # Audit provider + # Audit provider (auto-detect qp-capsule if installed) if auditor is not None: self._auditor = auditor else: - self._auditor = LogAuditor(self.path / "audit.jsonl") + try: + from qp_vault.audit.capsule_auditor import HAS_CAPSULE, CapsuleAuditor + if HAS_CAPSULE: + self._auditor = CapsuleAuditor() + else: + self._auditor = LogAuditor(self.path / "audit.jsonl") + except ImportError: + self._auditor = LogAuditor(self.path / "audit.jsonl") # Parsers and policies self._parsers = parsers or [] @@ -213,6 +220,7 @@ async def add( lifecycle: Lifecycle | str = Lifecycle.ACTIVE, valid_from: date | None = None, valid_until: date | None = None, + tenant_id: str | None = None, ) -> Resource: """Add a resource to the vault. @@ -325,6 +333,7 @@ async def add( lifecycle=lifecycle, valid_from=valid_from, valid_until=valid_until, + tenant_id=tenant_id, ) async def get(self, resource_id: str) -> Resource: @@ -335,6 +344,7 @@ async def get(self, resource_id: str) -> Resource: async def list( self, *, + tenant_id: str | None = None, trust: TrustTier | str | None = None, classification: DataClassification | str | None = None, layer: MemoryLayer | str | None = None, @@ -348,6 +358,7 @@ async def list( """List resources with optional filters.""" await self._ensure_initialized() return await self._resource_manager.list( + tenant_id=tenant_id, trust=trust, classification=classification, layer=layer, @@ -511,6 +522,7 @@ async def search( self, query: str, *, + tenant_id: str | None = None, top_k: int = 10, threshold: float = 0.0, trust_min: TrustTier | str | None = None, @@ -551,10 +563,11 @@ async def search( vector_weight=self.config.vector_weight, text_weight=self.config.text_weight, filters=ResourceFilter( + tenant_id=tenant_id, trust_tier=trust_min.value if hasattr(trust_min, "value") else trust_min, layer=layer.value if hasattr(layer, "value") else layer, collection_id=collection, - ) if any([trust_min, layer, collection]) else None, + ) if any([tenant_id, trust_min, layer, collection]) else None, as_of=str(as_of) if as_of else None, ) @@ -692,6 +705,36 @@ def layer(self, name: MemoryLayer | str) -> LayerView: layer_enum = MemoryLayer(name) if isinstance(name, str) else name return LayerView(layer_enum, self, self._layer_manager) + # --- Collections --- + + async def create_collection( + self, + name: str, + *, + description: str = "", + tenant_id: str | None = None, + ) -> dict[str, Any]: + """Create a new collection.""" + await self._ensure_initialized() + import uuid + from datetime import UTC, datetime + collection_id = str(uuid.uuid4()) + now = datetime.now(tz=UTC).isoformat() + conn = self._storage._get_conn() # type: ignore[union-attr] + conn.execute( + "INSERT INTO collections (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + (collection_id, name, description, now, now), + ) + conn.commit() + return {"id": collection_id, "name": name, "description": description} + + async def list_collections(self, *, tenant_id: str | None = None) -> list[dict[str, Any]]: + """List all collections.""" + await self._ensure_initialized() + conn = self._storage._get_conn() # type: ignore[union-attr] + rows = conn.execute("SELECT * FROM collections ORDER BY name").fetchall() + return [dict(r) for r in rows] + # --- Integrity --- async def health(self) -> HealthScore: @@ -885,6 +928,16 @@ def layer(self, name: MemoryLayer | str) -> LayerView: # type: ignore[return-va """Get a scoped view of a memory layer.""" return self._async.layer(name) # type: ignore[return-value] + def create_collection(self, name: str, **kwargs: Any) -> dict[str, Any]: + """Create a new collection.""" + result: dict[str, Any] = _run_async(self._async.create_collection(name, **kwargs)) + return result + + def list_collections(self, **kwargs: Any) -> list[dict[str, Any]]: + """List all collections.""" + result: list[dict[str, Any]] = _run_async(self._async.list_collections(**kwargs)) + return result + def health(self) -> HealthScore: """Compute vault health score.""" result: HealthScore = _run_async(self._async.health())