GT AI OS Community v2.0.33 - Add NVIDIA NIM and Nemotron agents
- Updated python_coding_microproject.csv to use NVIDIA NIM Kimi K2 - Updated kali_linux_shell_simulator.csv to use NVIDIA NIM Kimi K2 - Made more general-purpose (flexible targets, expanded tools) - Added nemotron-mini-agent.csv for fast local inference via Ollama - Added nemotron-agent.csv for advanced reasoning via Ollama - Added wiki page: Projects for NVIDIA NIMs and Nemotron
This commit is contained in:
38
packages/api-standards/setup.py
Normal file
38
packages/api-standards/setup.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Setup configuration for GT 2.0 API Standards package
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="gt2-api-standards",
|
||||
version="1.0.0",
|
||||
description="GT 2.0 Capability-Based REST (CB-REST) API Standards",
|
||||
author="GT Edge AI",
|
||||
author_email="engineering@gtedgeai.com",
|
||||
packages=find_packages(where="src"),
|
||||
package_dir={"": "src"},
|
||||
python_requires=">=3.11",
|
||||
install_requires=[
|
||||
"fastapi>=0.104.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.5.0",
|
||||
]
|
||||
},
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
)
|
||||
48
packages/api-standards/src/__init__.py
Normal file
48
packages/api-standards/src/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
GT 2.0 API Standards - Capability-Based REST (CB-REST)
|
||||
|
||||
A simple, secure API standard designed for GT 2.0's philosophy of
|
||||
"Elegant Simplicity Through Intelligent Architecture"
|
||||
"""
|
||||
|
||||
from .response import StandardResponse, StandardError, format_response, format_error
|
||||
from .capability import (
|
||||
Capability,
|
||||
CapabilityVerifier,
|
||||
verify_capability,
|
||||
require_capability,
|
||||
extract_capability_from_jwt
|
||||
)
|
||||
from .errors import ErrorCode, APIError, error_responses
|
||||
from .middleware import (
|
||||
CapabilityMiddleware,
|
||||
RequestCorrelationMiddleware,
|
||||
TenantIsolationMiddleware
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Response formatting
|
||||
'StandardResponse',
|
||||
'StandardError',
|
||||
'format_response',
|
||||
'format_error',
|
||||
|
||||
# Capability verification
|
||||
'Capability',
|
||||
'CapabilityVerifier',
|
||||
'verify_capability',
|
||||
'require_capability',
|
||||
'extract_capability_from_jwt',
|
||||
|
||||
# Error handling
|
||||
'ErrorCode',
|
||||
'APIError',
|
||||
'error_responses',
|
||||
|
||||
# Middleware
|
||||
'CapabilityMiddleware',
|
||||
'RequestCorrelationMiddleware',
|
||||
'TenantIsolationMiddleware'
|
||||
]
|
||||
|
||||
__version__ = '1.0.0'
|
||||
337
packages/api-standards/src/capability.py
Normal file
337
packages/api-standards/src/capability.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Capability-based access control for GT 2.0 CB-REST API
|
||||
|
||||
Design principle: Security through cryptographic capabilities embedded in JWT tokens
|
||||
Perfect tenant isolation through capability scoping
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from .errors import ErrorCode, APIError
|
||||
|
||||
|
||||
# Security scheme for FastAPI
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class Capability(BaseModel):
|
||||
"""
|
||||
A single capability grant
|
||||
|
||||
Format: resource:id:action
|
||||
Example: tenant:customer1:read
|
||||
"""
|
||||
resource: str = Field(..., description="Resource type (tenant, user, resource, etc.)")
|
||||
resource_id: str = Field(..., description="Specific resource ID or wildcard (*)")
|
||||
action: str = Field(..., description="Action allowed (read, write, create, delete, admin)")
|
||||
constraints: Optional[Dict[str, Any]] = Field(None, description="Additional constraints")
|
||||
expires_at: Optional[datetime] = Field(None, description="Expiration time for this capability")
|
||||
|
||||
def matches(self, required_resource: str, required_id: str, required_action: str) -> bool:
|
||||
"""
|
||||
Check if this capability matches the required permission
|
||||
|
||||
Supports wildcards (*) for resource_id and action
|
||||
"""
|
||||
# Check resource type
|
||||
if self.resource != required_resource and self.resource != "*":
|
||||
return False
|
||||
|
||||
# Check resource ID (supports wildcards)
|
||||
if self.resource_id != required_id and self.resource_id != "*":
|
||||
return False
|
||||
|
||||
# Check action (supports wildcards and hierarchical permissions)
|
||||
if self.action == "*" or self.action == "admin":
|
||||
return True # Admin has all permissions
|
||||
|
||||
if self.action == required_action:
|
||||
return True
|
||||
|
||||
# Hierarchical permissions: write includes read, delete includes write
|
||||
action_hierarchy = {
|
||||
"delete": ["write", "read"],
|
||||
"write": ["read"],
|
||||
"create": ["read"]
|
||||
}
|
||||
|
||||
if required_action in action_hierarchy.get(self.action, []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def to_string(self) -> str:
|
||||
"""Convert capability to string format"""
|
||||
return f"{self.resource}:{self.resource_id}:{self.action}"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, capability_str: str) -> "Capability":
|
||||
"""Parse capability from string format"""
|
||||
parts = capability_str.split(":")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid capability format: {capability_str}")
|
||||
|
||||
return cls(
|
||||
resource=parts[0],
|
||||
resource_id=parts[1],
|
||||
action=parts[2]
|
||||
)
|
||||
|
||||
|
||||
class CapabilityToken(BaseModel):
|
||||
"""
|
||||
JWT token payload with embedded capabilities
|
||||
"""
|
||||
sub: str = Field(..., description="Subject (user email or ID)")
|
||||
tenant_id: str = Field(..., description="Tenant ID for isolation")
|
||||
user_type: str = Field(..., description="User type (super_admin, gt_admin, tenant_admin, tenant_user)")
|
||||
capabilities: List[Dict[str, Any]] = Field(..., description="List of granted capabilities")
|
||||
iat: int = Field(..., description="Issued at timestamp")
|
||||
exp: int = Field(..., description="Expiration timestamp")
|
||||
jti: Optional[str] = Field(None, description="JWT ID for revocation")
|
||||
|
||||
def get_capabilities(self) -> List[Capability]:
|
||||
"""Convert capability dicts to Capability objects"""
|
||||
return [Capability(**cap) for cap in self.capabilities]
|
||||
|
||||
def has_capability(self, resource: str, resource_id: str, action: str) -> bool:
|
||||
"""Check if token has a specific capability"""
|
||||
for cap in self.get_capabilities():
|
||||
if cap.matches(resource, resource_id, action):
|
||||
# Check expiration if set
|
||||
if cap.expires_at and cap.expires_at < datetime.utcnow():
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class CapabilityVerifier:
|
||||
"""
|
||||
Verifies capabilities and signatures
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: str, algorithm: str = "HS256"):
|
||||
self.secret_key = secret_key
|
||||
self.algorithm = algorithm
|
||||
|
||||
def verify_token(self, token: str) -> CapabilityToken:
|
||||
"""
|
||||
Verify and decode a JWT capability token
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded CapabilityToken
|
||||
|
||||
Raises:
|
||||
APIError: If token is invalid or expired
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.secret_key,
|
||||
algorithms=[self.algorithm]
|
||||
)
|
||||
return CapabilityToken(**payload)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise APIError(
|
||||
code=ErrorCode.CAPABILITY_EXPIRED,
|
||||
message="Capability token has expired",
|
||||
status_code=401
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise APIError(
|
||||
code=ErrorCode.CAPABILITY_INVALID,
|
||||
message=f"Invalid capability token: {str(e)}",
|
||||
status_code=401
|
||||
)
|
||||
|
||||
def verify_signature(self, token: str, signature: str) -> bool:
|
||||
"""
|
||||
Verify HMAC signature of capability
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
signature: HMAC signature to verify
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
expected_signature = hmac.new(
|
||||
self.secret_key.encode(),
|
||||
token.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
def create_token(
|
||||
self,
|
||||
user_email: str,
|
||||
tenant_id: str,
|
||||
user_type: str,
|
||||
capabilities: List[Capability],
|
||||
expires_in: timedelta = timedelta(hours=24)
|
||||
) -> str:
|
||||
"""
|
||||
Create a new capability token
|
||||
|
||||
Args:
|
||||
user_email: User's email address
|
||||
tenant_id: Tenant ID for isolation
|
||||
user_type: Type of user
|
||||
capabilities: List of capabilities to grant
|
||||
expires_in: Token expiration time
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
exp = now + expires_in
|
||||
|
||||
payload = CapabilityToken(
|
||||
sub=user_email,
|
||||
tenant_id=tenant_id,
|
||||
user_type=user_type,
|
||||
capabilities=[cap.dict(exclude_none=True) for cap in capabilities],
|
||||
iat=int(now.timestamp()),
|
||||
exp=int(exp.timestamp())
|
||||
)
|
||||
|
||||
return jwt.encode(
|
||||
payload.dict(exclude_none=True),
|
||||
self.secret_key,
|
||||
algorithm=self.algorithm
|
||||
)
|
||||
|
||||
|
||||
# Global verifier instance (initialized by application)
|
||||
_verifier: Optional[CapabilityVerifier] = None
|
||||
|
||||
|
||||
def init_capability_verifier(secret_key: str, algorithm: str = "HS256"):
|
||||
"""Initialize the global capability verifier"""
|
||||
global _verifier
|
||||
_verifier = CapabilityVerifier(secret_key, algorithm)
|
||||
|
||||
|
||||
def get_verifier() -> CapabilityVerifier:
|
||||
"""Get the global capability verifier"""
|
||||
if _verifier is None:
|
||||
raise RuntimeError("Capability verifier not initialized. Call init_capability_verifier first.")
|
||||
return _verifier
|
||||
|
||||
|
||||
async def verify_capability(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
request: Request = None
|
||||
) -> CapabilityToken:
|
||||
"""
|
||||
FastAPI dependency to verify capability token
|
||||
|
||||
Args:
|
||||
credentials: Bearer token from Authorization header
|
||||
request: Optional request object for additional context
|
||||
|
||||
Returns:
|
||||
Verified CapabilityToken
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid
|
||||
"""
|
||||
verifier = get_verifier()
|
||||
|
||||
# Check for signature header if request provided
|
||||
signature = None
|
||||
if request:
|
||||
signature = request.headers.get("X-Capability-Signature")
|
||||
if signature and not verifier.verify_signature(credentials.credentials, signature):
|
||||
raise APIError(
|
||||
code=ErrorCode.CAPABILITY_SIGNATURE_INVALID,
|
||||
message="Capability signature verification failed",
|
||||
status_code=401
|
||||
)
|
||||
|
||||
return verifier.verify_token(credentials.credentials)
|
||||
|
||||
|
||||
def require_capability(
|
||||
resource: str,
|
||||
resource_id: str,
|
||||
action: str
|
||||
):
|
||||
"""
|
||||
Create a FastAPI dependency that requires a specific capability
|
||||
|
||||
Args:
|
||||
resource: Resource type
|
||||
resource_id: Resource ID (can be "*" for wildcard)
|
||||
action: Required action
|
||||
|
||||
Returns:
|
||||
FastAPI dependency function
|
||||
"""
|
||||
async def capability_checker(
|
||||
token: CapabilityToken = Depends(verify_capability)
|
||||
) -> CapabilityToken:
|
||||
"""Check if the token has the required capability"""
|
||||
|
||||
# Super admins bypass all checks (GT 2.0 admin efficiency)
|
||||
if token.user_type == "super_admin":
|
||||
return token
|
||||
|
||||
# GT admins can access all tenants but not system resources
|
||||
if token.user_type == "gt_admin" and resource in ["tenant", "user", "resource"]:
|
||||
return token
|
||||
|
||||
# Check specific capability
|
||||
if not token.has_capability(resource, resource_id, action):
|
||||
capability_required = f"{resource}:{resource_id}:{action}"
|
||||
|
||||
# Find what capabilities the user has for this resource
|
||||
user_caps = [
|
||||
cap.to_string()
|
||||
for cap in token.get_capabilities()
|
||||
if cap.resource == resource
|
||||
]
|
||||
|
||||
raise APIError(
|
||||
code=ErrorCode.CAPABILITY_INSUFFICIENT,
|
||||
message=f"Insufficient capability for {resource} {action}",
|
||||
status_code=403,
|
||||
capability_required=capability_required,
|
||||
capability_provided=", ".join(user_caps) if user_caps else "none"
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
return capability_checker
|
||||
|
||||
|
||||
def extract_capability_from_jwt(token_str: str) -> Optional[CapabilityToken]:
|
||||
"""
|
||||
Extract capability token without verification (for logging/debugging)
|
||||
|
||||
WARNING: This does not verify the token! Use only for non-security purposes.
|
||||
|
||||
Args:
|
||||
token_str: JWT token string
|
||||
|
||||
Returns:
|
||||
CapabilityToken if decodable, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Decode without verification
|
||||
payload = jwt.decode(token_str, options={"verify_signature": False})
|
||||
return CapabilityToken(**payload)
|
||||
except Exception:
|
||||
return None
|
||||
207
packages/api-standards/src/errors.py
Normal file
207
packages/api-standards/src/errors.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Standard error codes for GT 2.0 CB-REST API
|
||||
|
||||
Design principle: Simple, clear error codes that directly indicate the issue
|
||||
No complex hierarchies, just straightforward categories
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""
|
||||
Standard error codes following GT 2.0 philosophy:
|
||||
- Simple and clear
|
||||
- Security-focused
|
||||
- Tenant-aware
|
||||
"""
|
||||
|
||||
# Capability errors (security by design)
|
||||
CAPABILITY_INSUFFICIENT = "CAPABILITY_INSUFFICIENT"
|
||||
CAPABILITY_INVALID = "CAPABILITY_INVALID"
|
||||
CAPABILITY_EXPIRED = "CAPABILITY_EXPIRED"
|
||||
CAPABILITY_SIGNATURE_INVALID = "CAPABILITY_SIGNATURE_INVALID"
|
||||
|
||||
# Resource errors
|
||||
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
|
||||
RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS"
|
||||
RESOURCE_LOCKED = "RESOURCE_LOCKED"
|
||||
RESOURCE_QUOTA_EXCEEDED = "RESOURCE_QUOTA_EXCEEDED"
|
||||
|
||||
# Tenant isolation errors
|
||||
TENANT_ISOLATED = "TENANT_ISOLATED"
|
||||
TENANT_NOT_FOUND = "TENANT_NOT_FOUND"
|
||||
TENANT_SUSPENDED = "TENANT_SUSPENDED"
|
||||
TENANT_QUOTA_EXCEEDED = "TENANT_QUOTA_EXCEEDED"
|
||||
|
||||
# Request errors
|
||||
INVALID_REQUEST = "INVALID_REQUEST"
|
||||
VALIDATION_FAILED = "VALIDATION_FAILED"
|
||||
MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD"
|
||||
INVALID_FIELD_VALUE = "INVALID_FIELD_VALUE"
|
||||
|
||||
# System errors (minimal, as per GT 2.0 philosophy)
|
||||
SYSTEM_ERROR = "SYSTEM_ERROR"
|
||||
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
||||
|
||||
# File-based database specific errors
|
||||
FILE_LOCK_TIMEOUT = "FILE_LOCK_TIMEOUT"
|
||||
FILE_CORRUPTION = "FILE_CORRUPTION"
|
||||
ENCRYPTION_FAILED = "ENCRYPTION_FAILED"
|
||||
|
||||
|
||||
class APIError(HTTPException):
|
||||
"""
|
||||
Standard API error that integrates with FastAPI
|
||||
|
||||
Simplifies error handling while maintaining consistency
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: ErrorCode,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
capability_required: Optional[str] = None,
|
||||
capability_provided: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.capability_required = capability_required
|
||||
self.capability_provided = capability_provided
|
||||
self.details = details or {}
|
||||
|
||||
# Create the detail structure for HTTPException
|
||||
detail = {
|
||||
"code": code.value,
|
||||
"message": message,
|
||||
"capability_required": capability_required,
|
||||
"capability_provided": capability_provided,
|
||||
"details": self.details
|
||||
}
|
||||
|
||||
# Remove None values for cleaner response
|
||||
detail = {k: v for k, v in detail.items() if v is not None}
|
||||
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
# Pre-defined error responses for common scenarios
|
||||
error_responses: Dict[ErrorCode, Dict[str, Any]] = {
|
||||
ErrorCode.CAPABILITY_INSUFFICIENT: {
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
"description": "User lacks required capability for this operation"
|
||||
},
|
||||
ErrorCode.CAPABILITY_INVALID: {
|
||||
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||
"description": "Invalid capability token"
|
||||
},
|
||||
ErrorCode.CAPABILITY_EXPIRED: {
|
||||
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||
"description": "Capability token has expired"
|
||||
},
|
||||
ErrorCode.CAPABILITY_SIGNATURE_INVALID: {
|
||||
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||
"description": "Capability signature verification failed"
|
||||
},
|
||||
ErrorCode.RESOURCE_NOT_FOUND: {
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
"description": "Requested resource does not exist"
|
||||
},
|
||||
ErrorCode.RESOURCE_ALREADY_EXISTS: {
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"description": "Resource already exists"
|
||||
},
|
||||
ErrorCode.RESOURCE_LOCKED: {
|
||||
"status_code": status.HTTP_423_LOCKED,
|
||||
"description": "Resource is locked"
|
||||
},
|
||||
ErrorCode.RESOURCE_QUOTA_EXCEEDED: {
|
||||
"status_code": status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"description": "Resource quota exceeded"
|
||||
},
|
||||
ErrorCode.TENANT_ISOLATED: {
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
"description": "Cross-tenant access attempted"
|
||||
},
|
||||
ErrorCode.TENANT_NOT_FOUND: {
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
"description": "Tenant does not exist"
|
||||
},
|
||||
ErrorCode.TENANT_SUSPENDED: {
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
"description": "Tenant is suspended"
|
||||
},
|
||||
ErrorCode.TENANT_QUOTA_EXCEEDED: {
|
||||
"status_code": status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"description": "Tenant quota exceeded"
|
||||
},
|
||||
ErrorCode.INVALID_REQUEST: {
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
"description": "Invalid request format"
|
||||
},
|
||||
ErrorCode.VALIDATION_FAILED: {
|
||||
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"description": "Request validation failed"
|
||||
},
|
||||
ErrorCode.MISSING_REQUIRED_FIELD: {
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
"description": "Required field is missing"
|
||||
},
|
||||
ErrorCode.INVALID_FIELD_VALUE: {
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
"description": "Invalid field value"
|
||||
},
|
||||
ErrorCode.SYSTEM_ERROR: {
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"description": "Internal system error"
|
||||
},
|
||||
ErrorCode.SERVICE_UNAVAILABLE: {
|
||||
"status_code": status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"description": "Service temporarily unavailable"
|
||||
},
|
||||
ErrorCode.FILE_LOCK_TIMEOUT: {
|
||||
"status_code": status.HTTP_423_LOCKED,
|
||||
"description": "File database lock timeout"
|
||||
},
|
||||
ErrorCode.FILE_CORRUPTION: {
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"description": "File database corruption detected"
|
||||
},
|
||||
ErrorCode.ENCRYPTION_FAILED: {
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"description": "Encryption operation failed"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def raise_api_error(
|
||||
code: ErrorCode,
|
||||
message: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Convenience function to raise a standard API error
|
||||
|
||||
Args:
|
||||
code: Error code
|
||||
message: Optional custom message (uses default if not provided)
|
||||
**kwargs: Additional error details
|
||||
|
||||
Raises:
|
||||
APIError
|
||||
"""
|
||||
error_info = error_responses.get(code, {})
|
||||
|
||||
if message is None:
|
||||
message = error_info.get("description", "An error occurred")
|
||||
|
||||
raise APIError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=error_info.get("status_code", status.HTTP_400_BAD_REQUEST),
|
||||
**kwargs
|
||||
)
|
||||
329
packages/api-standards/src/middleware.py
Normal file
329
packages/api-standards/src/middleware.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Standard middleware for GT 2.0 CB-REST API
|
||||
|
||||
Design principle: Cross-cutting concerns handled simply and consistently
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from .capability import verify_capability, CapabilityToken, get_verifier
|
||||
from .response import format_error
|
||||
from .errors import ErrorCode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestCorrelationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Add request correlation ID to all requests for distributed tracing
|
||||
|
||||
GT 2.0 Philosophy: Simple observability through request tracking
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"):
|
||||
super().__init__(app)
|
||||
self.header_name = header_name
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Get or generate request ID
|
||||
request_id = request.headers.get(self.header_name)
|
||||
if not request_id:
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Store in request state for access by handlers
|
||||
request.state.request_id = request_id
|
||||
|
||||
# Process request
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Add headers to response
|
||||
response.headers[self.header_name] = request_id
|
||||
response.headers["X-Response-Time"] = f"{duration:.3f}s"
|
||||
|
||||
# Log request completion
|
||||
logger.info(
|
||||
f"Request {request_id} completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code,
|
||||
"duration": duration
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CapabilityMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Verify capabilities for all protected endpoints
|
||||
|
||||
GT 2.0 Philosophy: Security by design, not configuration
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
exclude_paths: Optional[list[str]] = None,
|
||||
audit_logger: Optional[logging.Logger] = None
|
||||
):
|
||||
super().__init__(app)
|
||||
self.exclude_paths = exclude_paths or ["/health", "/ready", "/metrics", "/docs", "/redoc"]
|
||||
self.audit_logger = audit_logger or logger
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Skip capability check for excluded paths
|
||||
if any(request.url.path.startswith(path) for path in self.exclude_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract and verify token
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return Response(
|
||||
content=format_error(
|
||||
code=ErrorCode.CAPABILITY_INVALID.value,
|
||||
message="Missing or invalid authorization header",
|
||||
capability_used="none",
|
||||
request_id=getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
),
|
||||
status_code=401,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
try:
|
||||
# Verify token
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
verifier = get_verifier()
|
||||
capability_token = verifier.verify_token(token)
|
||||
|
||||
# Store in request state for access by handlers
|
||||
request.state.capability_token = capability_token
|
||||
request.state.tenant_id = capability_token.tenant_id
|
||||
request.state.user_email = capability_token.sub
|
||||
|
||||
# Check signature if provided
|
||||
signature = request.headers.get("X-Capability-Signature")
|
||||
if signature and not verifier.verify_signature(token, signature):
|
||||
return Response(
|
||||
content=format_error(
|
||||
code=ErrorCode.CAPABILITY_SIGNATURE_INVALID.value,
|
||||
message="Invalid capability signature",
|
||||
capability_used="none",
|
||||
request_id=getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
),
|
||||
status_code=401,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Audit log successful capability usage
|
||||
self.audit_logger.info(
|
||||
f"Capability used: {request.method} {request.url.path}",
|
||||
extra={
|
||||
"request_id": getattr(request.state, "request_id", "unknown"),
|
||||
"tenant_id": capability_token.tenant_id,
|
||||
"user": capability_token.sub,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Capability verification failed: {e}")
|
||||
return Response(
|
||||
content=format_error(
|
||||
code=ErrorCode.CAPABILITY_INVALID.value,
|
||||
message=str(e),
|
||||
capability_used="none",
|
||||
request_id=getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
),
|
||||
status_code=401,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
|
||||
class TenantIsolationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Ensure perfect tenant isolation for all requests
|
||||
|
||||
GT 2.0 Philosophy: Security through architectural design
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
enforce_isolation: bool = True
|
||||
):
|
||||
super().__init__(app)
|
||||
self.enforce_isolation = enforce_isolation
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Skip for non-tenant paths
|
||||
if not self.enforce_isolation or request.url.path.startswith("/health"):
|
||||
return await call_next(request)
|
||||
|
||||
# Get tenant from capability token (set by CapabilityMiddleware)
|
||||
capability_token: Optional[CapabilityToken] = getattr(request.state, "capability_token", None)
|
||||
if not capability_token:
|
||||
return await call_next(request) # Let other middleware handle auth
|
||||
|
||||
# Extract tenant from path if present
|
||||
path_parts = request.url.path.strip("/").split("/")
|
||||
path_tenant = None
|
||||
|
||||
# Look for tenant ID in common patterns
|
||||
if "tenants" in path_parts:
|
||||
idx = path_parts.index("tenants")
|
||||
if idx + 1 < len(path_parts):
|
||||
path_tenant = path_parts[idx + 1]
|
||||
|
||||
# Check tenant isolation
|
||||
if path_tenant and capability_token.tenant_id != path_tenant:
|
||||
# Only super_admin and gt_admin can access other tenants
|
||||
if capability_token.user_type not in ["super_admin", "gt_admin"]:
|
||||
logger.warning(
|
||||
f"Tenant isolation violation attempted",
|
||||
extra={
|
||||
"request_id": getattr(request.state, "request_id", "unknown"),
|
||||
"user_tenant": capability_token.tenant_id,
|
||||
"requested_tenant": path_tenant,
|
||||
"user": capability_token.sub,
|
||||
"path": request.url.path
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=format_error(
|
||||
code=ErrorCode.TENANT_ISOLATED.value,
|
||||
message="Cross-tenant access not allowed",
|
||||
capability_used=f"tenant:{capability_token.tenant_id}:*",
|
||||
capability_required=f"tenant:{path_tenant}:*",
|
||||
request_id=getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
),
|
||||
status_code=403,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
# Add tenant context to request
|
||||
request.state.tenant_context = {
|
||||
"tenant_id": capability_token.tenant_id,
|
||||
"requested_tenant": path_tenant,
|
||||
"user_type": capability_token.user_type,
|
||||
"isolation_enforced": True
|
||||
}
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class FileSystemIsolationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Ensure file system access is properly isolated per tenant
|
||||
|
||||
GT 2.0 Philosophy: File-based databases with perfect isolation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
base_path: str = "/data"
|
||||
):
|
||||
super().__init__(app)
|
||||
self.base_path = base_path
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Get tenant from request state
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
if tenant_id:
|
||||
# Set allowed file paths for this tenant
|
||||
request.state.allowed_paths = [
|
||||
f"{self.base_path}/{tenant_id}",
|
||||
f"{self.base_path}/shared" # Shared read-only resources
|
||||
]
|
||||
|
||||
# Set file encryption key reference
|
||||
request.state.encryption_key_id = f"{tenant_id}-key-{time.strftime('%Y-%m')}"
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Simple rate limiting per capability
|
||||
|
||||
GT 2.0 Philosophy: Resource protection through simple limits
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
requests_per_minute: int = 60,
|
||||
cache: Optional[Dict[str, list[float]]] = None
|
||||
):
|
||||
super().__init__(app)
|
||||
self.requests_per_minute = requests_per_minute
|
||||
self.cache = cache or {} # In production, use Redis
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Skip rate limiting for health checks
|
||||
if request.url.path.startswith("/health"):
|
||||
return await call_next(request)
|
||||
|
||||
# Get user identifier from capability token
|
||||
capability_token: Optional[CapabilityToken] = getattr(request.state, "capability_token", None)
|
||||
if not capability_token:
|
||||
return await call_next(request)
|
||||
|
||||
# Create rate limit key
|
||||
key = f"{capability_token.tenant_id}:{capability_token.sub}"
|
||||
|
||||
# Check rate limit
|
||||
now = time.time()
|
||||
minute_ago = now - 60
|
||||
|
||||
# Get request times for this key
|
||||
request_times = self.cache.get(key, [])
|
||||
|
||||
# Remove old entries
|
||||
request_times = [t for t in request_times if t > minute_ago]
|
||||
|
||||
# Check if limit exceeded
|
||||
if len(request_times) >= self.requests_per_minute:
|
||||
return Response(
|
||||
content=format_error(
|
||||
code=ErrorCode.RESOURCE_QUOTA_EXCEEDED.value,
|
||||
message=f"Rate limit exceeded: {self.requests_per_minute} requests per minute",
|
||||
capability_used=f"tenant:{capability_token.tenant_id}:*",
|
||||
details={"retry_after": 60 - (now - request_times[0])},
|
||||
request_id=getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
),
|
||||
status_code=429,
|
||||
media_type="application/json",
|
||||
headers={"Retry-After": str(int(60 - (now - request_times[0])))}
|
||||
)
|
||||
|
||||
# Add current request time
|
||||
request_times.append(now)
|
||||
self.cache[key] = request_times
|
||||
|
||||
# Add rate limit headers to response
|
||||
response = await call_next(request)
|
||||
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
|
||||
response.headers["X-RateLimit-Remaining"] = str(self.requests_per_minute - len(request_times))
|
||||
response.headers["X-RateLimit-Reset"] = str(int(minute_ago + 60))
|
||||
|
||||
return response
|
||||
161
packages/api-standards/src/response.py
Normal file
161
packages/api-standards/src/response.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Standard response formatting for GT 2.0 CB-REST API
|
||||
|
||||
Design principle: Simple, consistent, secure responses with built-in audit trail
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StandardError(BaseModel):
|
||||
"""Standard error structure"""
|
||||
code: str = Field(..., description="Error code (e.g., CAPABILITY_INSUFFICIENT)")
|
||||
message: str = Field(..., description="Human-readable error message")
|
||||
capability_required: Optional[str] = Field(None, description="Required capability for this operation")
|
||||
capability_provided: Optional[str] = Field(None, description="Capability that was provided")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="Additional error details")
|
||||
|
||||
|
||||
class StandardResponse(BaseModel):
|
||||
"""
|
||||
Standard response format for all CB-REST endpoints
|
||||
|
||||
Philosophy: Every response has the same structure, reducing ambiguity
|
||||
"""
|
||||
data: Optional[Any] = Field(None, description="Response data (null on error)")
|
||||
error: Optional[StandardError] = Field(None, description="Error information (null on success)")
|
||||
capability_used: str = Field(..., description="Capability that authorized this request")
|
||||
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique request identifier")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
|
||||
def format_response(
|
||||
data: Any,
|
||||
capability_used: str,
|
||||
request_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a successful response
|
||||
|
||||
Args:
|
||||
data: The response data
|
||||
capability_used: The capability that authorized this request
|
||||
request_id: Optional request ID (will be generated if not provided)
|
||||
|
||||
Returns:
|
||||
Standardized response dictionary
|
||||
"""
|
||||
return StandardResponse(
|
||||
data=data,
|
||||
error=None,
|
||||
capability_used=capability_used,
|
||||
request_id=request_id or str(uuid.uuid4())
|
||||
).dict(exclude_none=True)
|
||||
|
||||
|
||||
def format_error(
|
||||
code: str,
|
||||
message: str,
|
||||
capability_used: str = "none",
|
||||
capability_required: Optional[str] = None,
|
||||
capability_provided: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format an error response
|
||||
|
||||
Args:
|
||||
code: Error code (e.g., CAPABILITY_INSUFFICIENT)
|
||||
message: Human-readable error message
|
||||
capability_used: The capability check that failed
|
||||
capability_required: The required capability
|
||||
capability_provided: The capability that was provided
|
||||
details: Additional error details
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Standardized error response dictionary
|
||||
"""
|
||||
error = StandardError(
|
||||
code=code,
|
||||
message=message,
|
||||
capability_required=capability_required,
|
||||
capability_provided=capability_provided,
|
||||
details=details
|
||||
)
|
||||
|
||||
return StandardResponse(
|
||||
data=None,
|
||||
error=error,
|
||||
capability_used=capability_used,
|
||||
request_id=request_id or str(uuid.uuid4())
|
||||
).dict(exclude_none=True)
|
||||
|
||||
|
||||
class BulkOperationResult(BaseModel):
|
||||
"""Result of a single operation in a bulk request"""
|
||||
operation_id: str = Field(..., description="Unique identifier for this operation")
|
||||
action: str = Field(..., description="Action performed (create, update, delete)")
|
||||
resource_id: Optional[str] = Field(None, description="ID of the affected resource")
|
||||
success: bool = Field(..., description="Whether the operation succeeded")
|
||||
error: Optional[StandardError] = Field(None, description="Error if operation failed")
|
||||
data: Optional[Any] = Field(None, description="Result data if operation succeeded")
|
||||
|
||||
|
||||
class BulkResponse(BaseModel):
|
||||
"""Response format for bulk operations"""
|
||||
operations: list[BulkOperationResult] = Field(..., description="Results of all operations")
|
||||
transaction: bool = Field(..., description="Whether all operations were in a transaction")
|
||||
total: int = Field(..., description="Total number of operations")
|
||||
succeeded: int = Field(..., description="Number of successful operations")
|
||||
failed: int = Field(..., description="Number of failed operations")
|
||||
capability_used: str = Field(..., description="Capability that authorized this bulk operation")
|
||||
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
|
||||
|
||||
def format_bulk_response(
|
||||
operations: list[BulkOperationResult],
|
||||
transaction: bool,
|
||||
capability_used: str,
|
||||
request_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a bulk operation response
|
||||
|
||||
Args:
|
||||
operations: List of operation results
|
||||
transaction: Whether operations were transactional
|
||||
capability_used: The capability that authorized this request
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Standardized bulk response dictionary
|
||||
"""
|
||||
succeeded = sum(1 for op in operations if op.success)
|
||||
failed = len(operations) - succeeded
|
||||
|
||||
response = BulkResponse(
|
||||
operations=operations,
|
||||
transaction=transaction,
|
||||
total=len(operations),
|
||||
succeeded=succeeded,
|
||||
failed=failed,
|
||||
capability_used=capability_used,
|
||||
request_id=request_id or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
# For bulk responses, we wrap in standard response
|
||||
return StandardResponse(
|
||||
data=response.dict(exclude_none=True),
|
||||
error=None,
|
||||
capability_used=capability_used,
|
||||
request_id=response.request_id
|
||||
).dict(exclude_none=True)
|
||||
Reference in New Issue
Block a user