feat(spaces): implement Phase 7 Composability
Implements space composition and inheritance features: - SpaceReference model for space-to-space references (includes, extends, links_to, composed_of) - Variable inheritance through parent chain with local override - Config inheritance with source tracking - Access control models (SpacePermission, SpaceRole, AccessLevel) - InheritanceResolver for walking parent chains - AccessControlService for permission management - ComposableSpaceService integrating all composability features - Circular reference detection for EXTENDS references - SQLite repositories for references and permissions - 57 comprehensive unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,29 @@ from .events import (
|
|||||||
reset_event_bus,
|
reset_event_bus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase 7: Composability
|
||||||
|
from .composability import (
|
||||||
|
# Models
|
||||||
|
SpaceReference,
|
||||||
|
SpaceReferenceType,
|
||||||
|
SpacePermission,
|
||||||
|
SpaceRole,
|
||||||
|
AccessLevel,
|
||||||
|
SpaceAccess,
|
||||||
|
InheritedVariable,
|
||||||
|
InheritedConfig,
|
||||||
|
# Services
|
||||||
|
ComposableSpaceService,
|
||||||
|
InheritanceResolver,
|
||||||
|
AccessControlService,
|
||||||
|
# Repositories
|
||||||
|
ISpaceReferenceRepository,
|
||||||
|
IAccessControlRepository,
|
||||||
|
SqliteSpaceReferenceRepository,
|
||||||
|
SqliteAccessControlRepository,
|
||||||
|
)
|
||||||
|
from .composability.service import CircularReferenceError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Models
|
# Models
|
||||||
"InformationSpace",
|
"InformationSpace",
|
||||||
@@ -88,4 +111,23 @@ __all__ = [
|
|||||||
"EventBus",
|
"EventBus",
|
||||||
"get_event_bus",
|
"get_event_bus",
|
||||||
"reset_event_bus",
|
"reset_event_bus",
|
||||||
|
# Composability - Models
|
||||||
|
"SpaceReference",
|
||||||
|
"SpaceReferenceType",
|
||||||
|
"SpacePermission",
|
||||||
|
"SpaceRole",
|
||||||
|
"AccessLevel",
|
||||||
|
"SpaceAccess",
|
||||||
|
"InheritedVariable",
|
||||||
|
"InheritedConfig",
|
||||||
|
# Composability - Services
|
||||||
|
"ComposableSpaceService",
|
||||||
|
"InheritanceResolver",
|
||||||
|
"AccessControlService",
|
||||||
|
"CircularReferenceError",
|
||||||
|
# Composability - Repositories
|
||||||
|
"ISpaceReferenceRepository",
|
||||||
|
"IAccessControlRepository",
|
||||||
|
"SqliteSpaceReferenceRepository",
|
||||||
|
"SqliteAccessControlRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
49
markitect/spaces/composability/__init__.py
Normal file
49
markitect/spaces/composability/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Composability module for Information Spaces.
|
||||||
|
|
||||||
|
Provides space-to-space references, variable/config inheritance,
|
||||||
|
and basic access control for the Information Space system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
SpaceReference,
|
||||||
|
SpaceReferenceType,
|
||||||
|
SpacePermission,
|
||||||
|
SpaceRole,
|
||||||
|
AccessLevel,
|
||||||
|
SpaceAccess,
|
||||||
|
InheritedVariable,
|
||||||
|
InheritedConfig,
|
||||||
|
)
|
||||||
|
from .service import (
|
||||||
|
ComposableSpaceService,
|
||||||
|
InheritanceResolver,
|
||||||
|
AccessControlService,
|
||||||
|
)
|
||||||
|
from .repository import (
|
||||||
|
ISpaceReferenceRepository,
|
||||||
|
IAccessControlRepository,
|
||||||
|
SqliteSpaceReferenceRepository,
|
||||||
|
SqliteAccessControlRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"SpaceReference",
|
||||||
|
"SpaceReferenceType",
|
||||||
|
"SpacePermission",
|
||||||
|
"SpaceRole",
|
||||||
|
"AccessLevel",
|
||||||
|
"SpaceAccess",
|
||||||
|
"InheritedVariable",
|
||||||
|
"InheritedConfig",
|
||||||
|
# Services
|
||||||
|
"ComposableSpaceService",
|
||||||
|
"InheritanceResolver",
|
||||||
|
"AccessControlService",
|
||||||
|
# Repositories
|
||||||
|
"ISpaceReferenceRepository",
|
||||||
|
"IAccessControlRepository",
|
||||||
|
"SqliteSpaceReferenceRepository",
|
||||||
|
"SqliteAccessControlRepository",
|
||||||
|
]
|
||||||
271
markitect/spaces/composability/models.py
Normal file
271
markitect/spaces/composability/models.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Domain models for space composability.
|
||||||
|
|
||||||
|
Defines models for space references, inheritance, and access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceReferenceType(Enum):
|
||||||
|
"""Type of reference between spaces."""
|
||||||
|
INCLUDES = "includes" # Space A includes content from Space B
|
||||||
|
EXTENDS = "extends" # Space A extends/inherits from Space B
|
||||||
|
LINKS_TO = "links_to" # Space A has a soft link to Space B
|
||||||
|
COMPOSED_OF = "composed_of" # Space A is composed of multiple spaces
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpaceReference:
|
||||||
|
"""
|
||||||
|
Represents a reference between two spaces.
|
||||||
|
|
||||||
|
Unlike parent_space_id (single inheritance), space references allow
|
||||||
|
multiple relationships between spaces for composition.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique reference identifier
|
||||||
|
source_space_id: The space making the reference
|
||||||
|
target_space_id: The space being referenced
|
||||||
|
reference_type: Type of reference relationship
|
||||||
|
alias: Optional alias for the referenced space
|
||||||
|
metadata: Additional reference metadata
|
||||||
|
created_at: When the reference was created
|
||||||
|
"""
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
source_space_id: str = ""
|
||||||
|
target_space_id: str = ""
|
||||||
|
reference_type: SpaceReferenceType = SpaceReferenceType.LINKS_TO
|
||||||
|
alias: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"source_space_id": self.source_space_id,
|
||||||
|
"target_space_id": self.target_space_id,
|
||||||
|
"reference_type": self.reference_type.value,
|
||||||
|
"alias": self.alias,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "SpaceReference":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
created_at = data.get("created_at")
|
||||||
|
if isinstance(created_at, str):
|
||||||
|
created_at = datetime.fromisoformat(created_at)
|
||||||
|
elif created_at is None:
|
||||||
|
created_at = datetime.now()
|
||||||
|
|
||||||
|
ref_type = data.get("reference_type", "links_to")
|
||||||
|
if isinstance(ref_type, str):
|
||||||
|
ref_type = SpaceReferenceType(ref_type)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=data.get("id", str(uuid.uuid4())),
|
||||||
|
source_space_id=data.get("source_space_id", ""),
|
||||||
|
target_space_id=data.get("target_space_id", ""),
|
||||||
|
reference_type=ref_type,
|
||||||
|
alias=data.get("alias"),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLevel(Enum):
|
||||||
|
"""Access level for space permissions."""
|
||||||
|
NONE = "none" # No access
|
||||||
|
READ = "read" # Read-only access
|
||||||
|
WRITE = "write" # Read and write access
|
||||||
|
ADMIN = "admin" # Full administrative access
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceRole(Enum):
|
||||||
|
"""Predefined roles for space access."""
|
||||||
|
OWNER = "owner" # Full control, can delete space
|
||||||
|
ADMIN = "admin" # Can manage members and settings
|
||||||
|
EDITOR = "editor" # Can edit documents and variables
|
||||||
|
VIEWER = "viewer" # Read-only access
|
||||||
|
GUEST = "guest" # Limited read access
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpacePermission:
|
||||||
|
"""
|
||||||
|
Permission entry for a space.
|
||||||
|
|
||||||
|
Maps a principal (user/group/role) to an access level for a space.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
space_id: The space this permission applies to
|
||||||
|
principal_type: Type of principal (user, group, role)
|
||||||
|
principal_id: Identifier for the principal
|
||||||
|
access_level: The access level granted
|
||||||
|
role: Optional predefined role
|
||||||
|
granted_by: Who granted this permission
|
||||||
|
granted_at: When permission was granted
|
||||||
|
expires_at: Optional expiration timestamp
|
||||||
|
"""
|
||||||
|
space_id: str
|
||||||
|
principal_type: str # "user", "group", "role"
|
||||||
|
principal_id: str
|
||||||
|
access_level: AccessLevel = AccessLevel.READ
|
||||||
|
role: Optional[SpaceRole] = None
|
||||||
|
granted_by: Optional[str] = None
|
||||||
|
granted_at: datetime = field(default_factory=datetime.now)
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"space_id": self.space_id,
|
||||||
|
"principal_type": self.principal_type,
|
||||||
|
"principal_id": self.principal_id,
|
||||||
|
"access_level": self.access_level.value,
|
||||||
|
"role": self.role.value if self.role else None,
|
||||||
|
"granted_by": self.granted_by,
|
||||||
|
"granted_at": self.granted_at.isoformat(),
|
||||||
|
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "SpacePermission":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
granted_at = data.get("granted_at")
|
||||||
|
if isinstance(granted_at, str):
|
||||||
|
granted_at = datetime.fromisoformat(granted_at)
|
||||||
|
elif granted_at is None:
|
||||||
|
granted_at = datetime.now()
|
||||||
|
|
||||||
|
expires_at = data.get("expires_at")
|
||||||
|
if isinstance(expires_at, str):
|
||||||
|
expires_at = datetime.fromisoformat(expires_at)
|
||||||
|
|
||||||
|
access_level = data.get("access_level", "read")
|
||||||
|
if isinstance(access_level, str):
|
||||||
|
access_level = AccessLevel(access_level)
|
||||||
|
|
||||||
|
role = data.get("role")
|
||||||
|
if isinstance(role, str):
|
||||||
|
role = SpaceRole(role)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
space_id=data["space_id"],
|
||||||
|
principal_type=data.get("principal_type", "user"),
|
||||||
|
principal_id=data["principal_id"],
|
||||||
|
access_level=access_level,
|
||||||
|
role=role,
|
||||||
|
granted_by=data.get("granted_by"),
|
||||||
|
granted_at=granted_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if permission has expired."""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return datetime.now() > self.expires_at
|
||||||
|
|
||||||
|
def has_access(self, required_level: AccessLevel) -> bool:
|
||||||
|
"""Check if this permission grants the required access level."""
|
||||||
|
if self.is_expired():
|
||||||
|
return False
|
||||||
|
|
||||||
|
level_order = [AccessLevel.NONE, AccessLevel.READ, AccessLevel.WRITE, AccessLevel.ADMIN]
|
||||||
|
return level_order.index(self.access_level) >= level_order.index(required_level)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpaceAccess:
|
||||||
|
"""
|
||||||
|
Computed access result for a principal on a space.
|
||||||
|
|
||||||
|
Combines all applicable permissions to determine effective access.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
space_id: The space being accessed
|
||||||
|
principal_id: The principal accessing
|
||||||
|
effective_level: Computed effective access level
|
||||||
|
roles: All roles that apply
|
||||||
|
inherited_from: Space IDs from which access was inherited
|
||||||
|
permissions: Source permissions that contributed
|
||||||
|
"""
|
||||||
|
space_id: str
|
||||||
|
principal_id: str
|
||||||
|
effective_level: AccessLevel = AccessLevel.NONE
|
||||||
|
roles: List[SpaceRole] = field(default_factory=list)
|
||||||
|
inherited_from: List[str] = field(default_factory=list)
|
||||||
|
permissions: List[SpacePermission] = field(default_factory=list)
|
||||||
|
|
||||||
|
def can_read(self) -> bool:
|
||||||
|
"""Check if principal can read."""
|
||||||
|
return self.effective_level in [AccessLevel.READ, AccessLevel.WRITE, AccessLevel.ADMIN]
|
||||||
|
|
||||||
|
def can_write(self) -> bool:
|
||||||
|
"""Check if principal can write."""
|
||||||
|
return self.effective_level in [AccessLevel.WRITE, AccessLevel.ADMIN]
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""Check if principal has admin access."""
|
||||||
|
return self.effective_level == AccessLevel.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InheritedVariable:
|
||||||
|
"""
|
||||||
|
A variable with its inheritance source.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Variable name
|
||||||
|
value: Variable value
|
||||||
|
source_space_id: Space where this variable is defined
|
||||||
|
inheritance_depth: How many levels up the inheritance chain (0 = local)
|
||||||
|
scope: Variable scope
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
value: Any
|
||||||
|
source_space_id: str
|
||||||
|
inheritance_depth: int = 0
|
||||||
|
scope: str = "space"
|
||||||
|
|
||||||
|
def is_local(self) -> bool:
|
||||||
|
"""Check if variable is locally defined (not inherited)."""
|
||||||
|
return self.inheritance_depth == 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InheritedConfig:
|
||||||
|
"""
|
||||||
|
Configuration with inheritance tracking.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
default_variant: Effective default variant
|
||||||
|
enable_caching: Effective caching setting
|
||||||
|
theme: Effective theme
|
||||||
|
history_enabled: Effective history setting
|
||||||
|
variable_scope: Effective variable scope
|
||||||
|
source_spaces: Map of config key to source space ID
|
||||||
|
"""
|
||||||
|
default_variant: str = "hierarchical"
|
||||||
|
enable_caching: bool = True
|
||||||
|
theme: Optional[str] = None
|
||||||
|
history_enabled: bool = False
|
||||||
|
variable_scope: str = "space"
|
||||||
|
source_spaces: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_source(self, key: str) -> Optional[str]:
|
||||||
|
"""Get the source space ID for a config key."""
|
||||||
|
return self.source_spaces.get(key)
|
||||||
|
|
||||||
|
def is_inherited(self, key: str, current_space_id: str) -> bool:
|
||||||
|
"""Check if a config key is inherited from a parent."""
|
||||||
|
source = self.source_spaces.get(key)
|
||||||
|
return source is not None and source != current_space_id
|
||||||
424
markitect/spaces/composability/repository.py
Normal file
424
markitect/spaces/composability/repository.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Repository implementations for space composability.
|
||||||
|
|
||||||
|
Provides storage for space references and access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
SpaceReference,
|
||||||
|
SpaceReferenceType,
|
||||||
|
SpacePermission,
|
||||||
|
AccessLevel,
|
||||||
|
SpaceRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ISpaceReferenceRepository(ABC):
|
||||||
|
"""Interface for space reference storage."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_reference(self, reference: SpaceReference) -> SpaceReference:
|
||||||
|
"""Add a space reference."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_reference(self, reference_id: str) -> Optional[SpaceReference]:
|
||||||
|
"""Get a reference by ID."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_references_from(
|
||||||
|
self, source_space_id: str, ref_type: Optional[SpaceReferenceType] = None
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""Get all references from a source space."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_references_to(
|
||||||
|
self, target_space_id: str, ref_type: Optional[SpaceReferenceType] = None
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""Get all references to a target space."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def remove_reference(self, reference_id: str) -> bool:
|
||||||
|
"""Remove a reference."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def remove_references_from(self, source_space_id: str) -> int:
|
||||||
|
"""Remove all references from a source space."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reference_exists(
|
||||||
|
self, source_space_id: str, target_space_id: str, ref_type: SpaceReferenceType
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a specific reference exists."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IAccessControlRepository(ABC):
|
||||||
|
"""Interface for access control storage."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def grant_permission(self, permission: SpacePermission) -> SpacePermission:
|
||||||
|
"""Grant a permission."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def revoke_permission(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""Revoke a permission."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_permissions_for_space(self, space_id: str) -> List[SpacePermission]:
|
||||||
|
"""Get all permissions for a space."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_permissions_for_principal(
|
||||||
|
self, principal_type: str, principal_id: str
|
||||||
|
) -> List[SpacePermission]:
|
||||||
|
"""Get all permissions for a principal."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_permission(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> Optional[SpacePermission]:
|
||||||
|
"""Get a specific permission."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear_space_permissions(self, space_id: str) -> int:
|
||||||
|
"""Clear all permissions for a space."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteSpaceReferenceRepository(ISpaceReferenceRepository):
|
||||||
|
"""SQLite implementation of space reference repository."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
"""Initialize the repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file
|
||||||
|
"""
|
||||||
|
self._db_path = db_path
|
||||||
|
self._init_tables()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""Get a database connection."""
|
||||||
|
conn = sqlite3.connect(str(self._db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_tables(self) -> None:
|
||||||
|
"""Initialize database tables."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS space_references (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_space_id TEXT NOT NULL,
|
||||||
|
target_space_id TEXT NOT NULL,
|
||||||
|
reference_type TEXT NOT NULL,
|
||||||
|
alias TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(source_space_id, target_space_id, reference_type)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_ref_source
|
||||||
|
ON space_references(source_space_id)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_ref_target
|
||||||
|
ON space_references(target_space_id)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def add_reference(self, reference: SpaceReference) -> SpaceReference:
|
||||||
|
"""Add a space reference."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO space_references
|
||||||
|
(id, source_space_id, target_space_id, reference_type, alias, metadata, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
reference.id,
|
||||||
|
reference.source_space_id,
|
||||||
|
reference.target_space_id,
|
||||||
|
reference.reference_type.value,
|
||||||
|
reference.alias,
|
||||||
|
json.dumps(reference.metadata),
|
||||||
|
reference.created_at.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return reference
|
||||||
|
|
||||||
|
def get_reference(self, reference_id: str) -> Optional[SpaceReference]:
|
||||||
|
"""Get a reference by ID."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM space_references WHERE id = ?", (reference_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return self._row_to_reference(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_references_from(
|
||||||
|
self, source_space_id: str, ref_type: Optional[SpaceReferenceType] = None
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""Get all references from a source space."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if ref_type:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_references
|
||||||
|
WHERE source_space_id = ? AND reference_type = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(source_space_id, ref_type.value),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_references
|
||||||
|
WHERE source_space_id = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(source_space_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_reference(row) for row in rows]
|
||||||
|
|
||||||
|
def get_references_to(
|
||||||
|
self, target_space_id: str, ref_type: Optional[SpaceReferenceType] = None
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""Get all references to a target space."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if ref_type:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_references
|
||||||
|
WHERE target_space_id = ? AND reference_type = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(target_space_id, ref_type.value),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_references
|
||||||
|
WHERE target_space_id = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(target_space_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_reference(row) for row in rows]
|
||||||
|
|
||||||
|
def remove_reference(self, reference_id: str) -> bool:
|
||||||
|
"""Remove a reference."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM space_references WHERE id = ?", (reference_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def remove_references_from(self, source_space_id: str) -> int:
|
||||||
|
"""Remove all references from a source space."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM space_references WHERE source_space_id = ?",
|
||||||
|
(source_space_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def reference_exists(
|
||||||
|
self, source_space_id: str, target_space_id: str, ref_type: SpaceReferenceType
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a specific reference exists."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM space_references
|
||||||
|
WHERE source_space_id = ? AND target_space_id = ? AND reference_type = ?
|
||||||
|
""",
|
||||||
|
(source_space_id, target_space_id, ref_type.value),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def _row_to_reference(self, row: sqlite3.Row) -> SpaceReference:
|
||||||
|
"""Convert a database row to a SpaceReference."""
|
||||||
|
return SpaceReference(
|
||||||
|
id=row["id"],
|
||||||
|
source_space_id=row["source_space_id"],
|
||||||
|
target_space_id=row["target_space_id"],
|
||||||
|
reference_type=SpaceReferenceType(row["reference_type"]),
|
||||||
|
alias=row["alias"],
|
||||||
|
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteAccessControlRepository(IAccessControlRepository):
|
||||||
|
"""SQLite implementation of access control repository."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
"""Initialize the repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file
|
||||||
|
"""
|
||||||
|
self._db_path = db_path
|
||||||
|
self._init_tables()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""Get a database connection."""
|
||||||
|
conn = sqlite3.connect(str(self._db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_tables(self) -> None:
|
||||||
|
"""Initialize database tables."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS space_permissions (
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
principal_type TEXT NOT NULL,
|
||||||
|
principal_id TEXT NOT NULL,
|
||||||
|
access_level TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
granted_by TEXT,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
PRIMARY KEY(space_id, principal_type, principal_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_perm_principal
|
||||||
|
ON space_permissions(principal_type, principal_id)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def grant_permission(self, permission: SpacePermission) -> SpacePermission:
|
||||||
|
"""Grant a permission."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO space_permissions
|
||||||
|
(space_id, principal_type, principal_id, access_level, role,
|
||||||
|
granted_by, granted_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
permission.space_id,
|
||||||
|
permission.principal_type,
|
||||||
|
permission.principal_id,
|
||||||
|
permission.access_level.value,
|
||||||
|
permission.role.value if permission.role else None,
|
||||||
|
permission.granted_by,
|
||||||
|
permission.granted_at.isoformat(),
|
||||||
|
permission.expires_at.isoformat() if permission.expires_at else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return permission
|
||||||
|
|
||||||
|
def revoke_permission(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""Revoke a permission."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM space_permissions
|
||||||
|
WHERE space_id = ? AND principal_type = ? AND principal_id = ?
|
||||||
|
""",
|
||||||
|
(space_id, principal_type, principal_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_permissions_for_space(self, space_id: str) -> List[SpacePermission]:
|
||||||
|
"""Get all permissions for a space."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM space_permissions WHERE space_id = ?", (space_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_permission(row) for row in rows]
|
||||||
|
|
||||||
|
def get_permissions_for_principal(
|
||||||
|
self, principal_type: str, principal_id: str
|
||||||
|
) -> List[SpacePermission]:
|
||||||
|
"""Get all permissions for a principal."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_permissions
|
||||||
|
WHERE principal_type = ? AND principal_id = ?
|
||||||
|
""",
|
||||||
|
(principal_type, principal_id),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_permission(row) for row in rows]
|
||||||
|
|
||||||
|
def get_permission(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> Optional[SpacePermission]:
|
||||||
|
"""Get a specific permission."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM space_permissions
|
||||||
|
WHERE space_id = ? AND principal_type = ? AND principal_id = ?
|
||||||
|
""",
|
||||||
|
(space_id, principal_type, principal_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return self._row_to_permission(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_space_permissions(self, space_id: str) -> int:
|
||||||
|
"""Clear all permissions for a space."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM space_permissions WHERE space_id = ?", (space_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def _row_to_permission(self, row: sqlite3.Row) -> SpacePermission:
|
||||||
|
"""Convert a database row to a SpacePermission."""
|
||||||
|
expires_at = row["expires_at"]
|
||||||
|
if expires_at:
|
||||||
|
expires_at = datetime.fromisoformat(expires_at)
|
||||||
|
|
||||||
|
role = row["role"]
|
||||||
|
if role:
|
||||||
|
role = SpaceRole(role)
|
||||||
|
|
||||||
|
return SpacePermission(
|
||||||
|
space_id=row["space_id"],
|
||||||
|
principal_type=row["principal_type"],
|
||||||
|
principal_id=row["principal_id"],
|
||||||
|
access_level=AccessLevel(row["access_level"]),
|
||||||
|
role=role,
|
||||||
|
granted_by=row["granted_by"],
|
||||||
|
granted_at=datetime.fromisoformat(row["granted_at"]),
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
825
markitect/spaces/composability/service.py
Normal file
825
markitect/spaces/composability/service.py
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
"""
|
||||||
|
Service layer for space composability.
|
||||||
|
|
||||||
|
Provides inheritance resolution, reference management, and access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Dict, Any, Set
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ..models import InformationSpace, SpaceVariable, SpaceConfig
|
||||||
|
from ..repositories.interfaces import ISpaceRepository, IVariableRepository
|
||||||
|
from .models import (
|
||||||
|
SpaceReference,
|
||||||
|
SpaceReferenceType,
|
||||||
|
SpacePermission,
|
||||||
|
AccessLevel,
|
||||||
|
SpaceRole,
|
||||||
|
SpaceAccess,
|
||||||
|
InheritedVariable,
|
||||||
|
InheritedConfig,
|
||||||
|
)
|
||||||
|
from .repository import ISpaceReferenceRepository, IAccessControlRepository
|
||||||
|
|
||||||
|
|
||||||
|
class CircularReferenceError(Exception):
|
||||||
|
"""Raised when a circular reference is detected."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InheritanceResolver:
|
||||||
|
"""
|
||||||
|
Resolves inherited values through the space hierarchy.
|
||||||
|
|
||||||
|
Handles variable inheritance, config inheritance, and reference traversal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
space_repo: ISpaceRepository,
|
||||||
|
variable_repo: IVariableRepository,
|
||||||
|
reference_repo: Optional[ISpaceReferenceRepository] = None,
|
||||||
|
max_depth: int = 10,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the resolver.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_repo: Repository for space lookups
|
||||||
|
variable_repo: Repository for variable lookups
|
||||||
|
reference_repo: Optional repository for reference lookups
|
||||||
|
max_depth: Maximum inheritance depth to prevent infinite loops
|
||||||
|
"""
|
||||||
|
self._space_repo = space_repo
|
||||||
|
self._variable_repo = variable_repo
|
||||||
|
self._reference_repo = reference_repo
|
||||||
|
self._max_depth = max_depth
|
||||||
|
|
||||||
|
def get_parent_chain(
|
||||||
|
self, space_id: str, visited: Optional[Set[str]] = None
|
||||||
|
) -> List[InformationSpace]:
|
||||||
|
"""
|
||||||
|
Get the chain of parent spaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Starting space ID
|
||||||
|
visited: Set of already-visited space IDs (for cycle detection)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of parent spaces, from immediate parent to root
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CircularReferenceError: If a cycle is detected
|
||||||
|
"""
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
if space_id in visited:
|
||||||
|
raise CircularReferenceError(
|
||||||
|
f"Circular reference detected: space {space_id} already in chain"
|
||||||
|
)
|
||||||
|
|
||||||
|
visited.add(space_id)
|
||||||
|
space = self._space_repo.get_by_id(space_id)
|
||||||
|
|
||||||
|
if not space or not space.parent_space_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(visited) > self._max_depth:
|
||||||
|
raise CircularReferenceError(
|
||||||
|
f"Maximum inheritance depth ({self._max_depth}) exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
parent = self._space_repo.get_by_id(space.parent_space_id)
|
||||||
|
if not parent:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [parent] + self.get_parent_chain(parent.id, visited)
|
||||||
|
|
||||||
|
def resolve_variable(
|
||||||
|
self, space_id: str, name: str
|
||||||
|
) -> Optional[InheritedVariable]:
|
||||||
|
"""
|
||||||
|
Resolve a variable, checking parent spaces if not found locally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space to start resolution from
|
||||||
|
name: Variable name to resolve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InheritedVariable with value and source, or None if not found
|
||||||
|
"""
|
||||||
|
# Check local space first
|
||||||
|
local_var = self._variable_repo.get_variable(space_id, name)
|
||||||
|
if local_var:
|
||||||
|
return InheritedVariable(
|
||||||
|
name=name,
|
||||||
|
value=local_var.value,
|
||||||
|
source_space_id=space_id,
|
||||||
|
inheritance_depth=0,
|
||||||
|
scope=local_var.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Walk up parent chain
|
||||||
|
try:
|
||||||
|
parents = self.get_parent_chain(space_id)
|
||||||
|
except CircularReferenceError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for depth, parent in enumerate(parents, start=1):
|
||||||
|
parent_var = self._variable_repo.get_variable(parent.id, name)
|
||||||
|
if parent_var:
|
||||||
|
return InheritedVariable(
|
||||||
|
name=name,
|
||||||
|
value=parent_var.value,
|
||||||
|
source_space_id=parent.id,
|
||||||
|
inheritance_depth=depth,
|
||||||
|
scope=parent_var.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_all_variables(
|
||||||
|
self, space_id: str, scope: Optional[str] = None
|
||||||
|
) -> Dict[str, InheritedVariable]:
|
||||||
|
"""
|
||||||
|
Resolve all variables for a space, including inherited ones.
|
||||||
|
|
||||||
|
Variables defined locally override inherited ones with the same name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
scope: Optional scope filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of variable name to InheritedVariable
|
||||||
|
"""
|
||||||
|
result: Dict[str, InheritedVariable] = {}
|
||||||
|
|
||||||
|
# Start from the furthest ancestor and work down
|
||||||
|
try:
|
||||||
|
parents = self.get_parent_chain(space_id)
|
||||||
|
except CircularReferenceError:
|
||||||
|
parents = []
|
||||||
|
|
||||||
|
# Build list from root to current: [grandparent, parent, current]
|
||||||
|
# parents is [parent, grandparent] so reverse it
|
||||||
|
current_space = self._space_repo.get_by_id(space_id)
|
||||||
|
all_spaces = list(reversed(parents))
|
||||||
|
if current_space:
|
||||||
|
all_spaces.append(current_space)
|
||||||
|
|
||||||
|
# Process from root to current so local values override inherited ones
|
||||||
|
# Index 0 is root (highest depth), last index is current (depth 0)
|
||||||
|
total = len(all_spaces)
|
||||||
|
for i, space in enumerate(all_spaces):
|
||||||
|
# depth_from_current: current=0, parent=1, grandparent=2, etc.
|
||||||
|
depth_from_current = total - 1 - i
|
||||||
|
variables = self._variable_repo.list_variables(space.id, scope)
|
||||||
|
for var in variables:
|
||||||
|
result[var.name] = InheritedVariable(
|
||||||
|
name=var.name,
|
||||||
|
value=var.value,
|
||||||
|
source_space_id=space.id,
|
||||||
|
inheritance_depth=depth_from_current,
|
||||||
|
scope=var.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def resolve_config(self, space_id: str) -> InheritedConfig:
|
||||||
|
"""
|
||||||
|
Resolve the effective configuration for a space.
|
||||||
|
|
||||||
|
Config values are inherited from parents unless overridden locally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InheritedConfig with effective values and sources
|
||||||
|
"""
|
||||||
|
space = self._space_repo.get_by_id(space_id)
|
||||||
|
if not space:
|
||||||
|
return InheritedConfig()
|
||||||
|
|
||||||
|
# Start with defaults
|
||||||
|
result = InheritedConfig(
|
||||||
|
default_variant="hierarchical",
|
||||||
|
enable_caching=True,
|
||||||
|
theme=None,
|
||||||
|
history_enabled=False,
|
||||||
|
variable_scope="space",
|
||||||
|
source_spaces={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get parent chain
|
||||||
|
try:
|
||||||
|
parents = self.get_parent_chain(space_id)
|
||||||
|
except CircularReferenceError:
|
||||||
|
parents = []
|
||||||
|
|
||||||
|
# Process from root to current (reversed order)
|
||||||
|
all_spaces = list(reversed(parents)) + [space]
|
||||||
|
|
||||||
|
for s in all_spaces:
|
||||||
|
config = s.config
|
||||||
|
if config.default_variant:
|
||||||
|
result.default_variant = config.default_variant
|
||||||
|
result.source_spaces["default_variant"] = s.id
|
||||||
|
|
||||||
|
if config.enable_caching is not None:
|
||||||
|
result.enable_caching = config.enable_caching
|
||||||
|
result.source_spaces["enable_caching"] = s.id
|
||||||
|
|
||||||
|
if config.theme:
|
||||||
|
result.theme = config.theme
|
||||||
|
result.source_spaces["theme"] = s.id
|
||||||
|
|
||||||
|
if config.history_enabled is not None:
|
||||||
|
result.history_enabled = config.history_enabled
|
||||||
|
result.source_spaces["history_enabled"] = s.id
|
||||||
|
|
||||||
|
if config.variable_scope:
|
||||||
|
result.variable_scope = config.variable_scope
|
||||||
|
result.source_spaces["variable_scope"] = s.id
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AccessControlService:
|
||||||
|
"""
|
||||||
|
Service for managing space access control.
|
||||||
|
|
||||||
|
Handles permission grants, checks, and inheritance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
permission_repo: IAccessControlRepository,
|
||||||
|
space_repo: ISpaceRepository,
|
||||||
|
inheritance_resolver: Optional[InheritanceResolver] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_repo: Repository for permission storage
|
||||||
|
space_repo: Repository for space lookups
|
||||||
|
inheritance_resolver: Optional resolver for inherited permissions
|
||||||
|
"""
|
||||||
|
self._permission_repo = permission_repo
|
||||||
|
self._space_repo = space_repo
|
||||||
|
self._inheritance_resolver = inheritance_resolver
|
||||||
|
|
||||||
|
def grant_permission(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
access_level: AccessLevel,
|
||||||
|
role: Optional[SpaceRole] = None,
|
||||||
|
granted_by: Optional[str] = None,
|
||||||
|
) -> SpacePermission:
|
||||||
|
"""
|
||||||
|
Grant a permission to a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal (user, group, role)
|
||||||
|
principal_id: Principal identifier
|
||||||
|
access_level: Access level to grant
|
||||||
|
role: Optional role assignment
|
||||||
|
granted_by: Who is granting the permission
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created permission
|
||||||
|
"""
|
||||||
|
permission = SpacePermission(
|
||||||
|
space_id=space_id,
|
||||||
|
principal_type=principal_type,
|
||||||
|
principal_id=principal_id,
|
||||||
|
access_level=access_level,
|
||||||
|
role=role,
|
||||||
|
granted_by=granted_by,
|
||||||
|
)
|
||||||
|
return self._permission_repo.grant_permission(permission)
|
||||||
|
|
||||||
|
def revoke_permission(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Revoke a permission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if revoked, False if not found
|
||||||
|
"""
|
||||||
|
return self._permission_repo.revoke_permission(
|
||||||
|
space_id, principal_type, principal_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_access(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
required_level: AccessLevel = AccessLevel.READ,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a principal has the required access level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
required_level: Required access level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if access is granted
|
||||||
|
"""
|
||||||
|
access = self.get_effective_access(space_id, principal_type, principal_id)
|
||||||
|
if access.effective_level == AccessLevel.NONE:
|
||||||
|
return False
|
||||||
|
# Compare using level order, not string values
|
||||||
|
level_order = [AccessLevel.NONE, AccessLevel.READ, AccessLevel.WRITE, AccessLevel.ADMIN]
|
||||||
|
return level_order.index(access.effective_level) >= level_order.index(required_level)
|
||||||
|
|
||||||
|
def get_effective_access(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> SpaceAccess:
|
||||||
|
"""
|
||||||
|
Get the effective access for a principal on a space.
|
||||||
|
|
||||||
|
Considers direct permissions and inherited permissions from parent spaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SpaceAccess with computed effective access
|
||||||
|
"""
|
||||||
|
result = SpaceAccess(
|
||||||
|
space_id=space_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
effective_level=AccessLevel.NONE,
|
||||||
|
roles=[],
|
||||||
|
inherited_from=[],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check direct permission
|
||||||
|
direct = self._permission_repo.get_permission(
|
||||||
|
space_id, principal_type, principal_id
|
||||||
|
)
|
||||||
|
if direct and not direct.is_expired():
|
||||||
|
result.permissions.append(direct)
|
||||||
|
result.effective_level = direct.access_level
|
||||||
|
if direct.role:
|
||||||
|
result.roles.append(direct.role)
|
||||||
|
|
||||||
|
# Check inherited permissions if we have a resolver
|
||||||
|
if self._inheritance_resolver:
|
||||||
|
try:
|
||||||
|
parents = self._inheritance_resolver.get_parent_chain(space_id)
|
||||||
|
except CircularReferenceError:
|
||||||
|
parents = []
|
||||||
|
|
||||||
|
for parent in parents:
|
||||||
|
parent_perm = self._permission_repo.get_permission(
|
||||||
|
parent.id, principal_type, principal_id
|
||||||
|
)
|
||||||
|
if parent_perm and not parent_perm.is_expired():
|
||||||
|
result.permissions.append(parent_perm)
|
||||||
|
result.inherited_from.append(parent.id)
|
||||||
|
|
||||||
|
# Take the highest access level
|
||||||
|
level_order = [
|
||||||
|
AccessLevel.NONE,
|
||||||
|
AccessLevel.READ,
|
||||||
|
AccessLevel.WRITE,
|
||||||
|
AccessLevel.ADMIN,
|
||||||
|
]
|
||||||
|
if level_order.index(parent_perm.access_level) > level_order.index(
|
||||||
|
result.effective_level
|
||||||
|
):
|
||||||
|
result.effective_level = parent_perm.access_level
|
||||||
|
|
||||||
|
if parent_perm.role and parent_perm.role not in result.roles:
|
||||||
|
result.roles.append(parent_perm.role)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_space_permissions(self, space_id: str) -> List[SpacePermission]:
|
||||||
|
"""
|
||||||
|
Get all permissions for a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of permissions
|
||||||
|
"""
|
||||||
|
return self._permission_repo.get_permissions_for_space(space_id)
|
||||||
|
|
||||||
|
def get_principal_permissions(
|
||||||
|
self, principal_type: str, principal_id: str
|
||||||
|
) -> List[SpacePermission]:
|
||||||
|
"""
|
||||||
|
Get all permissions for a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of permissions
|
||||||
|
"""
|
||||||
|
return self._permission_repo.get_permissions_for_principal(
|
||||||
|
principal_type, principal_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposableSpaceService:
|
||||||
|
"""
|
||||||
|
Extended space service with composability features.
|
||||||
|
|
||||||
|
Provides space references, inheritance resolution, and access control
|
||||||
|
on top of the base SpaceService functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
space_repo: ISpaceRepository,
|
||||||
|
variable_repo: IVariableRepository,
|
||||||
|
reference_repo: ISpaceReferenceRepository,
|
||||||
|
permission_repo: IAccessControlRepository,
|
||||||
|
max_inheritance_depth: int = 10,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_repo: Repository for spaces
|
||||||
|
variable_repo: Repository for variables
|
||||||
|
reference_repo: Repository for space references
|
||||||
|
permission_repo: Repository for permissions
|
||||||
|
max_inheritance_depth: Maximum inheritance chain depth
|
||||||
|
"""
|
||||||
|
self._space_repo = space_repo
|
||||||
|
self._variable_repo = variable_repo
|
||||||
|
self._reference_repo = reference_repo
|
||||||
|
self._permission_repo = permission_repo
|
||||||
|
|
||||||
|
self._inheritance_resolver = InheritanceResolver(
|
||||||
|
space_repo=space_repo,
|
||||||
|
variable_repo=variable_repo,
|
||||||
|
reference_repo=reference_repo,
|
||||||
|
max_depth=max_inheritance_depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._access_service = AccessControlService(
|
||||||
|
permission_repo=permission_repo,
|
||||||
|
space_repo=space_repo,
|
||||||
|
inheritance_resolver=self._inheritance_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Space References
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def add_reference(
|
||||||
|
self,
|
||||||
|
source_space_id: str,
|
||||||
|
target_space_id: str,
|
||||||
|
reference_type: SpaceReferenceType = SpaceReferenceType.LINKS_TO,
|
||||||
|
alias: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SpaceReference:
|
||||||
|
"""
|
||||||
|
Add a reference from one space to another.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_space_id: The space making the reference
|
||||||
|
target_space_id: The space being referenced
|
||||||
|
reference_type: Type of reference
|
||||||
|
alias: Optional alias for the target space
|
||||||
|
metadata: Optional metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created reference
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If spaces don't exist or reference would create a cycle
|
||||||
|
CircularReferenceError: If reference would create a circular dependency
|
||||||
|
"""
|
||||||
|
# Validate spaces exist
|
||||||
|
if not self._space_repo.exists(source_space_id):
|
||||||
|
raise ValueError(f"Source space '{source_space_id}' not found")
|
||||||
|
if not self._space_repo.exists(target_space_id):
|
||||||
|
raise ValueError(f"Target space '{target_space_id}' not found")
|
||||||
|
|
||||||
|
# Check for self-reference
|
||||||
|
if source_space_id == target_space_id:
|
||||||
|
raise ValueError("Cannot reference self")
|
||||||
|
|
||||||
|
# Check for circular reference (for EXTENDS type)
|
||||||
|
if reference_type == SpaceReferenceType.EXTENDS:
|
||||||
|
self._check_circular_reference(source_space_id, target_space_id)
|
||||||
|
|
||||||
|
reference = SpaceReference(
|
||||||
|
source_space_id=source_space_id,
|
||||||
|
target_space_id=target_space_id,
|
||||||
|
reference_type=reference_type,
|
||||||
|
alias=alias,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._reference_repo.add_reference(reference)
|
||||||
|
|
||||||
|
def _check_circular_reference(
|
||||||
|
self, source_id: str, target_id: str, visited: Optional[Set[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Check if adding a reference would create a cycle."""
|
||||||
|
if visited is None:
|
||||||
|
visited = {source_id}
|
||||||
|
|
||||||
|
if target_id in visited:
|
||||||
|
raise CircularReferenceError(
|
||||||
|
f"Adding reference would create cycle: {target_id} already in chain"
|
||||||
|
)
|
||||||
|
|
||||||
|
visited.add(target_id)
|
||||||
|
|
||||||
|
# Check existing references from target
|
||||||
|
refs = self._reference_repo.get_references_from(
|
||||||
|
target_id, SpaceReferenceType.EXTENDS
|
||||||
|
)
|
||||||
|
for ref in refs:
|
||||||
|
self._check_circular_reference(source_id, ref.target_space_id, visited)
|
||||||
|
|
||||||
|
# Also check parent_space_id chain
|
||||||
|
target_space = self._space_repo.get_by_id(target_id)
|
||||||
|
if target_space and target_space.parent_space_id:
|
||||||
|
if target_space.parent_space_id in visited:
|
||||||
|
raise CircularReferenceError(
|
||||||
|
f"Adding reference would create cycle through parent: "
|
||||||
|
f"{target_space.parent_space_id}"
|
||||||
|
)
|
||||||
|
self._check_circular_reference(
|
||||||
|
source_id, target_space.parent_space_id, visited
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_reference(self, reference_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a space reference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_id: The reference ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed, False if not found
|
||||||
|
"""
|
||||||
|
return self._reference_repo.remove_reference(reference_id)
|
||||||
|
|
||||||
|
def get_references_from(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
reference_type: Optional[SpaceReferenceType] = None,
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""
|
||||||
|
Get all references from a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The source space ID
|
||||||
|
reference_type: Optional filter by type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of references
|
||||||
|
"""
|
||||||
|
return self._reference_repo.get_references_from(space_id, reference_type)
|
||||||
|
|
||||||
|
def get_references_to(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
reference_type: Optional[SpaceReferenceType] = None,
|
||||||
|
) -> List[SpaceReference]:
|
||||||
|
"""
|
||||||
|
Get all references to a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The target space ID
|
||||||
|
reference_type: Optional filter by type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of references
|
||||||
|
"""
|
||||||
|
return self._reference_repo.get_references_to(space_id, reference_type)
|
||||||
|
|
||||||
|
def get_composed_spaces(self, space_id: str) -> List[InformationSpace]:
|
||||||
|
"""
|
||||||
|
Get all spaces that compose this space.
|
||||||
|
|
||||||
|
Includes parent, extended spaces, and included spaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of composed spaces
|
||||||
|
"""
|
||||||
|
result: List[InformationSpace] = []
|
||||||
|
seen: Set[str] = {space_id}
|
||||||
|
|
||||||
|
space = self._space_repo.get_by_id(space_id)
|
||||||
|
if not space:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Add parent
|
||||||
|
if space.parent_space_id:
|
||||||
|
parent = self._space_repo.get_by_id(space.parent_space_id)
|
||||||
|
if parent and parent.id not in seen:
|
||||||
|
result.append(parent)
|
||||||
|
seen.add(parent.id)
|
||||||
|
|
||||||
|
# Add extended spaces
|
||||||
|
extends_refs = self._reference_repo.get_references_from(
|
||||||
|
space_id, SpaceReferenceType.EXTENDS
|
||||||
|
)
|
||||||
|
for ref in extends_refs:
|
||||||
|
if ref.target_space_id not in seen:
|
||||||
|
target = self._space_repo.get_by_id(ref.target_space_id)
|
||||||
|
if target:
|
||||||
|
result.append(target)
|
||||||
|
seen.add(ref.target_space_id)
|
||||||
|
|
||||||
|
# Add included spaces
|
||||||
|
includes_refs = self._reference_repo.get_references_from(
|
||||||
|
space_id, SpaceReferenceType.INCLUDES
|
||||||
|
)
|
||||||
|
for ref in includes_refs:
|
||||||
|
if ref.target_space_id not in seen:
|
||||||
|
target = self._space_repo.get_by_id(ref.target_space_id)
|
||||||
|
if target:
|
||||||
|
result.append(target)
|
||||||
|
seen.add(ref.target_space_id)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Inheritance Resolution
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def resolve_variable(
|
||||||
|
self, space_id: str, name: str
|
||||||
|
) -> Optional[InheritedVariable]:
|
||||||
|
"""
|
||||||
|
Resolve a variable with inheritance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
name: Variable name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InheritedVariable or None if not found
|
||||||
|
"""
|
||||||
|
return self._inheritance_resolver.resolve_variable(space_id, name)
|
||||||
|
|
||||||
|
def resolve_all_variables(
|
||||||
|
self, space_id: str, scope: Optional[str] = None
|
||||||
|
) -> Dict[str, InheritedVariable]:
|
||||||
|
"""
|
||||||
|
Resolve all variables including inherited ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
scope: Optional scope filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of variable name to InheritedVariable
|
||||||
|
"""
|
||||||
|
return self._inheritance_resolver.resolve_all_variables(space_id, scope)
|
||||||
|
|
||||||
|
def resolve_config(self, space_id: str) -> InheritedConfig:
|
||||||
|
"""
|
||||||
|
Resolve effective configuration with inheritance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InheritedConfig with effective values
|
||||||
|
"""
|
||||||
|
return self._inheritance_resolver.resolve_config(space_id)
|
||||||
|
|
||||||
|
def get_parent_chain(self, space_id: str) -> List[InformationSpace]:
|
||||||
|
"""
|
||||||
|
Get the parent space chain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of parent spaces from immediate parent to root
|
||||||
|
"""
|
||||||
|
return self._inheritance_resolver.get_parent_chain(space_id)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Access Control
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def grant_access(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
access_level: AccessLevel,
|
||||||
|
role: Optional[SpaceRole] = None,
|
||||||
|
granted_by: Optional[str] = None,
|
||||||
|
) -> SpacePermission:
|
||||||
|
"""
|
||||||
|
Grant access to a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal (user, group)
|
||||||
|
principal_id: Principal identifier
|
||||||
|
access_level: Access level to grant
|
||||||
|
role: Optional role
|
||||||
|
granted_by: Who is granting access
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created permission
|
||||||
|
"""
|
||||||
|
return self._access_service.grant_permission(
|
||||||
|
space_id, principal_type, principal_id, access_level, role, granted_by
|
||||||
|
)
|
||||||
|
|
||||||
|
def revoke_access(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Revoke access to a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if revoked
|
||||||
|
"""
|
||||||
|
return self._access_service.revoke_permission(
|
||||||
|
space_id, principal_type, principal_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_access(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
principal_type: str,
|
||||||
|
principal_id: str,
|
||||||
|
required_level: AccessLevel = AccessLevel.READ,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a principal has required access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
required_level: Required access level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if access is granted
|
||||||
|
"""
|
||||||
|
return self._access_service.check_access(
|
||||||
|
space_id, principal_type, principal_id, required_level
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_effective_access(
|
||||||
|
self, space_id: str, principal_type: str, principal_id: str
|
||||||
|
) -> SpaceAccess:
|
||||||
|
"""
|
||||||
|
Get effective access including inheritance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
principal_type: Type of principal
|
||||||
|
principal_id: Principal identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SpaceAccess with computed access
|
||||||
|
"""
|
||||||
|
return self._access_service.get_effective_access(
|
||||||
|
space_id, principal_type, principal_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_space_permissions(self, space_id: str) -> List[SpacePermission]:
|
||||||
|
"""Get all permissions for a space."""
|
||||||
|
return self._access_service.get_space_permissions(space_id)
|
||||||
1099
tests/unit/spaces/test_composability.py
Normal file
1099
tests/unit/spaces/test_composability.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user