Metadata-Version: 2.4
Name: domain-security
Version: 0.1.0
Summary: Cross-cutting security concerns: authorization, tenancy, secrets, context management
Project-URL: Homepage, https://pypi.org/project/domain-security/
Project-URL: Repository, https://github.com/jekhator/domain-security
Project-URL: Issues, https://github.com/jekhator/domain-security/issues
Project-URL: Changelog, https://github.com/jekhator/domain-security/blob/main/CHANGELOG.md
Author: James Ekhator
License: Apache-2.0
License-File: LICENSE
Keywords: authorization,context,secrets,security,tenancy
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: domain-errors>=0.1.0
Description-Content-Type: text/markdown

# domain-security

[![PyPI](https://img.shields.io/pypi/v/domain-security)](https://pypi.org/project/domain-security/)
[![CI](https://github.com/jekhator/domain-security/actions/workflows/ci.yml/badge.svg)](https://github.com/jekhator/domain-security/actions)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)

Ambient security context management, scope-based authorization, tenant isolation, and secrets resolution for Python services. Provides a composable foundation for authorization, tenancy enforcement, and secret handling across asynchronous and synchronous code.

## Why domain-security?

Authorization policies, tenant boundaries, and secret handling cut across every service. domain-security provides a single, testable foundation: bind a Principal and tenant_id once, then enforce them declaratively with decorators (`@requires`, `@tenant_scoped`) and inline checks. Context is stored in Python's ContextVar, flowing automatically through async tasks, thread pools, and call stacks. Error types inherit from domain-errors, integrating with structured logging. Secrets are resolved lazily at call time, never stored as literals. Immutable frozen dataclasses mean no accidental mutation.

## Installation

```bash
pip install domain-security
```

Or with uv:

```bash
uv add domain-security
```

Requires Python 3.11+. Depends on domain-errors >= 0.1.0.

## Quick Start

### 1. Bind the ambient security context

```python
from domain_security import Principal, SecurityContext, SecurityContextManager

# Create a principal with scopes.
principal = Principal(id="user123", roles=frozenset(["admin"]), scopes=frozenset(["docs:read", "docs:write"]))
ctx = SecurityContext(principal=principal, tenant_id="acme-corp")

# Bind it for the duration of a request or async task.
manager = SecurityContextManager()
with manager.bind(principal=principal, tenant_id="acme-corp"):
    # Context is now ambient; decorators and manual checks see it.
    pass
```

### 2. Enforce permissions with @requires

```python
from domain_security import requires, AuthzError

@requires("docs:write")
def create_document(title: str) -> dict:
    return {"id": "doc1", "title": title}

# With context bound and the principal has the "docs:write" scope, the call succeeds.
# Without context or if the principal lacks the scope, AuthzError is raised.
```

### 3. Enforce tenant boundaries with @tenant_scoped

```python
from domain_security import tenant_scoped, TenancyError

@tenant_scoped("tenant_id")
def delete_document(doc_id: str, tenant_id: str) -> None:
    pass

# The call succeeds only if the ambient context's tenant_id matches the argument.
# Mismatch raises TenancyError.
```

You can also bind tenant_id to a class instance:

```python
class DocumentService:
    def __init__(self, tenant_id: str):
        self.tenant_id = tenant_id
    
    @tenant_scoped("self.tenant_id")
    def list_documents(self) -> list:
        return []
```

### 4. Resolve secrets at call time

```python
from domain_security import SecretRef, SecretValue, SecretError

class MySecretsBackend:
    def fetch(self, name: str) -> str:
        if name == "api_key":
            return "secret123"
        raise ValueError(f"Unknown secret: {name}")

ref = SecretRef("api_key")
backend = MySecretsBackend()

try:
    secret = ref.resolve(backend)
    # secret is a SecretValue; its repr is masked.
    print(repr(secret))  # <SecretValue ***>
    plaintext = secret.get()  # "secret123"
except SecretError as e:
    # Backend errors are wrapped as SecretError with __cause__ preserved.
    print(f"Secret access failed: {e}")
```

### 5. Manually check permissions

```python
from domain_security import Authorizer, Permission, SecurityContext, Principal

principal = Principal(id="user1", scopes=frozenset(["admin"]))
ctx = SecurityContext(principal=principal, tenant_id="tenant1")

authorizer = Authorizer()
decision = authorizer.check(ctx, Permission("admin"))
if not decision.allowed:
    print(f"Denied: {decision.reason}")

# If you call require() instead of check(), it raises AuthzError on denial.
authorizer.require(ctx, Permission("admin"))
```

## Public API

### SecurityContext and Principal

```python
from domain_security import SecurityContext, Principal

# Frozen dataclasses; immutable after creation.
principal = Principal(
    id: str,                               # Principal identifier
    roles: frozenset[str] = frozenset(),  # Role memberships
    scopes: frozenset[str] = frozenset()  # Delegated scopes
)

ctx = SecurityContext(
    principal: Principal | None,  # Authenticated principal (None = anonymous)
    tenant_id: str | None         # Tenant isolation boundary
)
```

### SecurityContextManager

```python
from domain_security import SecurityContextManager

manager = SecurityContextManager()

# Store context and return a reset token.
token = manager.set(ctx)

# Retrieve the current ambient context (or None).
ctx = manager.get()

# Temporarily bind a context, restoring the prior one on exit.
with manager.bind(principal=principal, tenant_id="tenant1"):
    # Code here runs with the bound context.
    pass

# Reset the context to the state before a matching set() call.
manager.clear(token)
```

### Authorizer and Permission

```python
from domain_security import Authorizer, Permission, PolicyDecision

authorizer = Authorizer()

# Evaluate a permission against the context. Returns PolicyDecision.
decision = authorizer.check(ctx, Permission("scope_name"))
# decision.allowed: bool
# decision.reason: str | None

# Enforce the permission, raising AuthzError if denied.
authorizer.require(ctx, Permission("scope_name"))  # May raise AuthzError.
```

Subclass `Authorizer` and override `check()` to implement richer policy sources (databases, external services).

### SecretRef and SecretValue

```python
from domain_security import SecretRef, SecretValue, SecretError, SecretsBackend

# Create a reference to a secret by name.
ref = SecretRef(name: str)

# Resolve it against a backend at call time.
secret = ref.resolve(backend: SecretsBackend | None = None)
# Returns: SecretValue
# Raises: SecretError if backend is None or fetch() fails

# Read the plaintext secret.
plaintext = secret.get() -> str

# repr() masks the value to prevent accidental logging.
repr(secret)  # <SecretValue ***>
```

Implement `SecretsBackend` as a Protocol:

```python
class MySecretsBackend:
    def fetch(self, name: str) -> str:
        # Return the plaintext secret or raise any exception.
        # Exceptions are wrapped as SecretError.
        ...
```

### Decorators

```python
from domain_security import requires, tenant_scoped

# Enforce a permission before the decorated method runs.
@requires("permission_name")
def my_method() -> None:
    pass

# Enforce a tenant boundary using a named argument or self.tenant_id.
@tenant_scoped("tenant_id")
def another_method(tenant_id: str) -> None:
    pass

@tenant_scoped("self.tenant_id")  # Binds to the instance's tenant_id attribute.
def instance_method(self) -> None:
    pass
```

Both decorators read the ambient context. No principal or missing scope/tenant raises the corresponding error.

### Error Types

```python
from domain_security import SecurityError, AuthzError, TenancyError, SecretError

# Base error for all security-domain failures.
# Subclasses DomainError; code="security_error", http_status=403
class SecurityError(DomainError):
    pass

# Permission denied by authorization policy.
# code="authz_denied", http_status=403
class AuthzError(SecurityError):
    pass

# Operation crossed or lacked a tenant boundary.
# code="tenant_boundary_violation", http_status=403
class TenancyError(SecurityError):
    pass

# Secret resolution or access failure.
# code="secret_access_failed", http_status=500
class SecretError(SecurityError):
    pass
```

All errors preserve `__cause__` when wrapping exceptions, integrate with domain-errors' structured logging, and carry typed context parameters.

## Documentation

For detailed documentation on each feature, see:

- [SecurityContext and SecurityContextManager](docs/apps/security_context.md)
- [Authorization with Authorizer and @requires](docs/apps/authz.md)
- [Tenant Isolation with TenancyGuard and @tenant_scoped](docs/apps/tenancy.md)
- [Secrets Management with SecretRef and SecretValue](docs/apps/secrets.md)
- [@requires decorator](docs/apps/requires.md)
- [@tenant_scoped decorator](docs/apps/tenant_scoped.md)
- [Error Types and Semantics](docs/apps/security_errors.md)

## License

Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details.

## Contributing

This library is maintained by [James Ekhator](https://github.com/jekhator). Contributions welcome via pull requests. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community standards.
