hexastack-auth 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ from hexastack_auth import adapters, domain, infra, ports
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ "ports",
8
+ ]
@@ -0,0 +1,21 @@
1
+ from hexastack_auth.adapters.grpc import AuthServerInterceptor
2
+ from hexastack_auth.adapters.in_memory import (
3
+ InMemoryPasswordHasher,
4
+ InMemorySecurityService,
5
+ )
6
+ from hexastack_auth.adapters.jwt import JwtSecurityAdapter
7
+ from hexastack_auth.adapters.opa import OpaPolicyAdapter
8
+ from hexastack_auth.adapters.openfga import OpenFgaPolicyAdapter
9
+ from hexastack_auth.adapters.password import Pbkdf2PasswordHasher
10
+ from hexastack_auth.adapters.spiffe import SpiffeWorkloadAdapter
11
+
12
+ __all__ = [
13
+ "AuthServerInterceptor",
14
+ "InMemoryPasswordHasher",
15
+ "InMemorySecurityService",
16
+ "JwtSecurityAdapter",
17
+ "OpaPolicyAdapter",
18
+ "OpenFgaPolicyAdapter",
19
+ "Pbkdf2PasswordHasher",
20
+ "SpiffeWorkloadAdapter",
21
+ ]
@@ -0,0 +1,136 @@
1
+ """FastAPI route guard dependencies for hexastack-auth (OPA & OpenFGA).
2
+
3
+ Notes/Architectural Intent:
4
+ Provides direct HTTP route-level policy and relationship guards for FastAPI.
5
+ FastAPI is an optional dependency of hexastack-auth[fastapi].
6
+ """
7
+
8
+ import importlib.util
9
+ from typing import Any
10
+
11
+ from hexastack_auth.domain.models import Identity
12
+ from hexastack_auth.ports.policy import AuthorizationPolicyPort
13
+ from hexastack_core.domain.exceptions import MissingDependencyError
14
+ from hexastack_core.utils.context import get_user_context
15
+
16
+ __all__ = [
17
+ "require_policy",
18
+ "require_relation",
19
+ ]
20
+
21
+
22
+ def _require_fastapi() -> None:
23
+ if importlib.util.find_spec("fastapi") is None:
24
+ raise MissingDependencyError(
25
+ "fastapi is required for FastAPI auth dependencies. "
26
+ "Install with 'pip install hexastack-auth[fastapi]'."
27
+ )
28
+
29
+
30
+ def require_policy(
31
+ policy_path: str,
32
+ *,
33
+ resource: str = "http_request",
34
+ status_code: int = 403,
35
+ detail: str | None = None,
36
+ ) -> Any:
37
+ """FastAPI route guard dependency enforcing an OPA or custom policy check.
38
+
39
+ Args:
40
+ policy_path: OPA policy endpoint (e.g. 'v1/data/reports/view').
41
+ resource: Target resource string.
42
+ status_code: HTTP status code to return when denied (defaults to 403).
43
+ detail: Custom error detail message.
44
+
45
+ Returns:
46
+ FastAPI async dependency callable.
47
+ """
48
+ _require_fastapi()
49
+ from fastapi import HTTPException, Request
50
+
51
+ async def _dependency(request: Request) -> None:
52
+ container = getattr(request.app.state, "container", None)
53
+ if container is None or AuthorizationPolicyPort not in container:
54
+ raise HTTPException(
55
+ status_code=500,
56
+ detail="AuthorizationPolicyPort is not configured in DI container.",
57
+ )
58
+
59
+ policy_port = container.resolve(AuthorizationPolicyPort)
60
+ user_ctx = get_user_context()
61
+ identity = Identity(
62
+ user_id=user_ctx.user_id if user_ctx else "anonymous",
63
+ roles=frozenset(user_ctx.roles if user_ctx else ()),
64
+ tenant_id=user_ctx.tenant_id if user_ctx else None,
65
+ is_authenticated=bool(user_ctx and user_ctx.user_id),
66
+ )
67
+
68
+ allowed = policy_port.is_authorized(
69
+ identity=identity,
70
+ action=policy_path,
71
+ resource=resource,
72
+ context={"url": str(request.url), "method": request.method},
73
+ )
74
+ if not allowed:
75
+ raise HTTPException(
76
+ status_code=status_code,
77
+ detail=detail or f"Access denied by policy '{policy_path}'.",
78
+ )
79
+
80
+ return _dependency
81
+
82
+
83
+ def require_relation(
84
+ relation: str,
85
+ object_type: str,
86
+ object_id: str | None = None,
87
+ *,
88
+ status_code: int = 403,
89
+ detail: str | None = None,
90
+ ) -> Any:
91
+ """FastAPI route guard dependency enforcing an OpenFGA relationship check.
92
+
93
+ Args:
94
+ relation: OpenFGA relationship name (e.g. 'can_edit', 'viewer').
95
+ object_type: Target object type (e.g. 'document', 'project').
96
+ object_id: Explicit object identifier or None.
97
+ status_code: HTTP status code to return when denied (defaults to 403).
98
+ detail: Custom error detail message.
99
+
100
+ Returns:
101
+ FastAPI async dependency callable.
102
+ """
103
+ _require_fastapi()
104
+ from fastapi import HTTPException, Request
105
+
106
+ async def _dependency(request: Request) -> None:
107
+ container = getattr(request.app.state, "container", None)
108
+ if container is None or AuthorizationPolicyPort not in container:
109
+ raise HTTPException(
110
+ status_code=500,
111
+ detail="AuthorizationPolicyPort is not configured in DI container.",
112
+ )
113
+
114
+ policy_port = container.resolve(AuthorizationPolicyPort)
115
+ user_ctx = get_user_context()
116
+ identity = Identity(
117
+ user_id=user_ctx.user_id if user_ctx else "anonymous",
118
+ roles=frozenset(user_ctx.roles if user_ctx else ()),
119
+ tenant_id=user_ctx.tenant_id if user_ctx else None,
120
+ is_authenticated=bool(user_ctx and user_ctx.user_id),
121
+ )
122
+
123
+ target_obj = f"{object_type}:{object_id or 'default'}"
124
+ allowed = policy_port.is_authorized(
125
+ identity=identity,
126
+ action=relation,
127
+ resource=target_obj,
128
+ )
129
+ if not allowed:
130
+ raise HTTPException(
131
+ status_code=status_code,
132
+ detail=detail
133
+ or f"Access denied: missing '{relation}' on '{target_obj}'.",
134
+ )
135
+
136
+ return _dependency
@@ -0,0 +1,100 @@
1
+ """gRPC Server Interceptors and security helpers for hexastack-auth.
2
+
3
+ Notes/Architectural Intent:
4
+ Extracts Bearer tokens or SPIFFE JWT-SVIDs from gRPC invocation metadata,
5
+ validates them against SecurityPort or WorkloadIdentityPort, and establishes
6
+ the ambient UserContext for the duration of the RPC.
7
+ """
8
+
9
+ import importlib.util
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from hexastack_auth.ports.security import SecurityPort
14
+ from hexastack_auth.ports.workload import WorkloadIdentityPort
15
+ from hexastack_core.domain.exceptions import MissingDependencyError
16
+ from hexastack_core.utils.context import UserContext, set_user_context
17
+
18
+ __all__ = [
19
+ "AuthServerInterceptor",
20
+ ]
21
+
22
+
23
+ class AuthServerInterceptor:
24
+ """gRPC server interceptor extracting credentials from metadata into UserContext."""
25
+
26
+ def __init__(
27
+ self,
28
+ security_port: SecurityPort | None = None,
29
+ workload_port: WorkloadIdentityPort | None = None,
30
+ *,
31
+ auth_header: str = "authorization",
32
+ spiffe_header: str = "x-spiffe-id",
33
+ required: bool = False,
34
+ ) -> None:
35
+ _require_grpc()
36
+ self._security_port = security_port
37
+ self._workload_port = workload_port
38
+ self._auth_header = auth_header.lower()
39
+ self._spiffe_header = spiffe_header.lower()
40
+ self._required = required
41
+
42
+ def intercept_service(
43
+ self,
44
+ continuation: Callable[[Any], Any],
45
+ handler_call_details: Any,
46
+ ) -> Any:
47
+ """Inspect invocation metadata and populate ambient UserContext."""
48
+ import grpc
49
+
50
+ metadata = dict(getattr(handler_call_details, "invocation_metadata", ()))
51
+ token = metadata.get(self._auth_header)
52
+ spiffe_id = metadata.get(self._spiffe_header)
53
+
54
+ user_ctx: UserContext | None = None
55
+
56
+ if token and self._security_port:
57
+ raw_token = token.removeprefix("Bearer ").strip()
58
+ try:
59
+ identity = self._security_port.verify_token(raw_token)
60
+ user_ctx = UserContext(
61
+ user_id=identity.user_id,
62
+ roles=list(identity.roles),
63
+ tenant_id=identity.tenant_id,
64
+ )
65
+ except Exception:
66
+ if self._required:
67
+ return grpc.unary_unary_rpc_method_handler(
68
+ lambda _req, ctx: ctx.abort(
69
+ grpc.StatusCode.UNAUTHENTICATED,
70
+ "Invalid or expired security token",
71
+ )
72
+ )
73
+
74
+ elif spiffe_id and self._workload_port:
75
+ user_ctx = UserContext(
76
+ user_id=spiffe_id,
77
+ roles=["workload"],
78
+ tenant_id=None,
79
+ )
80
+
81
+ elif self._required:
82
+ return grpc.unary_unary_rpc_method_handler(
83
+ lambda _req, ctx: ctx.abort(
84
+ grpc.StatusCode.UNAUTHENTICATED,
85
+ "Authentication credentials are required",
86
+ )
87
+ )
88
+
89
+ if user_ctx is not None:
90
+ set_user_context(user_ctx)
91
+
92
+ return continuation(handler_call_details)
93
+
94
+
95
+ def _require_grpc() -> None:
96
+ if importlib.util.find_spec("grpc") is None:
97
+ raise MissingDependencyError(
98
+ "grpc is required for gRPC auth interceptor. "
99
+ "Install with 'pip install hexastack-auth[grpc]'."
100
+ )
@@ -0,0 +1,101 @@
1
+ import secrets
2
+ import time
3
+ from datetime import timedelta
4
+
5
+ from hexastack_auth.domain.exceptions import (
6
+ InvalidTokenError,
7
+ TokenExpiredError,
8
+ )
9
+ from hexastack_auth.domain.models import Identity
10
+ from hexastack_auth.ports.password import PasswordHasherPort
11
+ from hexastack_auth.ports.security import SecurityPort
12
+
13
+
14
+ class InMemorySecurityService(SecurityPort):
15
+ """In-memory security service implementation for fast, isolated testing.
16
+
17
+ Notes/Architectural Intent:
18
+ Stores issued tokens in a local dictionary with expiration timestamps.
19
+ Eliminates cryptographic overhead in unit and property tests.
20
+ """
21
+
22
+ def __init__(self, default_ttl_seconds: int = 3600) -> None:
23
+ """Initialize in-memory token store."""
24
+ self._default_ttl_seconds = default_ttl_seconds
25
+ self._tokens: dict[str, tuple[Identity, float | None]] = {}
26
+
27
+ def clear(self) -> None:
28
+ """Clear all stored tokens."""
29
+ self._tokens.clear()
30
+
31
+ def create_token(
32
+ self,
33
+ identity: Identity,
34
+ *,
35
+ ttl: timedelta | int | None = None,
36
+ ) -> str:
37
+ """Store identity and return a unique token key.
38
+
39
+ Args:
40
+ identity: Identity to associate with token.
41
+ ttl: Optional TTL duration or seconds.
42
+
43
+ Returns:
44
+ Opaque token string.
45
+ """
46
+ token = f"mem_token_{secrets.token_urlsafe(24)}"
47
+ if ttl is None:
48
+ ttl_secs = self._default_ttl_seconds
49
+ elif isinstance(ttl, timedelta):
50
+ ttl_secs = int(ttl.total_seconds())
51
+ else:
52
+ ttl_secs = ttl
53
+
54
+ exp_time = time.time() + ttl_secs if ttl_secs is not None else None
55
+ self._tokens[token] = (identity, exp_time)
56
+ return token
57
+
58
+ def verify_token(self, token: str) -> Identity:
59
+ """Look up identity by token key and check expiration.
60
+
61
+ Args:
62
+ token: Opaque token string.
63
+
64
+ Returns:
65
+ Associated Identity.
66
+
67
+ Raises:
68
+ InvalidTokenError: If token does not exist.
69
+ TokenExpiredError: If token has expired.
70
+ """
71
+ if not token or token not in self._tokens:
72
+ raise InvalidTokenError(f"Token '{token}' not recognized.")
73
+
74
+ identity, exp_time = self._tokens[token]
75
+ if exp_time is not None and time.time() > exp_time:
76
+ del self._tokens[token]
77
+ raise TokenExpiredError("In-memory token has expired.")
78
+
79
+ return identity
80
+
81
+
82
+ class InMemoryPasswordHasher(PasswordHasherPort):
83
+ """In-memory password hasher for rapid unit testing without key derivation delay.
84
+
85
+ Notes/Architectural Intent:
86
+ Prefixes plain password with 'mock_hash:' for instantaneous test execution.
87
+ """
88
+
89
+ def hash_password(self, plain_password: str) -> str:
90
+ """Return a mock hashed password string."""
91
+ return f"mock_hash:{plain_password}"
92
+
93
+ def verify_password(self, plain_password: str, hashed_password: str) -> bool:
94
+ """Verify plain password matches mock hash."""
95
+ return hashed_password == f"mock_hash:{plain_password}"
96
+
97
+
98
+ __all__ = [
99
+ "InMemoryPasswordHasher",
100
+ "InMemorySecurityService",
101
+ ]
@@ -0,0 +1,159 @@
1
+ from datetime import UTC, datetime, timedelta
2
+ from typing import Any
3
+
4
+ import jwt
5
+
6
+ from hexastack_auth.domain.exceptions import (
7
+ AuthError,
8
+ InvalidTokenError,
9
+ TokenExpiredError,
10
+ )
11
+ from hexastack_auth.domain.models import Identity
12
+ from hexastack_auth.ports.security import SecurityPort
13
+
14
+
15
+ class JwtSecurityAdapter(SecurityPort):
16
+ """Concrete token security adapter implementing SecurityPort using PyJWT.
17
+
18
+ Notes/Architectural Intent:
19
+ Implements industry-standard JSON Web Token encoding and cryptographic signature
20
+ validation. Maps PyJWT exception hierarchies into clean Hexastack domain errors.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ secret_key: str,
26
+ *,
27
+ algorithm: str = "HS256",
28
+ default_ttl_seconds: int = 3600,
29
+ issuer: str | None = None,
30
+ audience: str | None = None,
31
+ ) -> None:
32
+ """Initialize JWT security adapter.
33
+
34
+ Args:
35
+ secret_key: Secret key or private key for signing tokens.
36
+ algorithm: Cryptographic algorithm (default 'HS256').
37
+ default_ttl_seconds: Default expiration lifespan in seconds (default 3600).
38
+ issuer: Optional expected token issuer string ('iss').
39
+ audience: Optional expected token audience string ('aud').
40
+ """
41
+ self._secret_key = secret_key
42
+ self._algorithm = algorithm
43
+ self._default_ttl = timedelta(seconds=default_ttl_seconds)
44
+ self._issuer = issuer
45
+ self._audience = audience
46
+
47
+ def create_token(
48
+ self,
49
+ identity: Identity,
50
+ *,
51
+ ttl: timedelta | int | None = None,
52
+ ) -> str:
53
+ """Issue a signed JWT token for the given identity.
54
+
55
+ Args:
56
+ identity: Identity to encode into claims.
57
+ ttl: Optional TTL duration or seconds override.
58
+
59
+ Returns:
60
+ Signed JWT string.
61
+
62
+ Raises:
63
+ AuthError: If encoding fails.
64
+ """
65
+ now = datetime.now(UTC)
66
+ if ttl is None:
67
+ effective_ttl = self._default_ttl
68
+ elif isinstance(ttl, int):
69
+ effective_ttl = timedelta(seconds=ttl)
70
+ else:
71
+ effective_ttl = ttl
72
+
73
+ exp = now + effective_ttl
74
+ payload: dict[str, Any] = {
75
+ "sub": identity.user_id,
76
+ "roles": sorted(identity.roles),
77
+ "permissions": sorted(identity.permissions),
78
+ "iat": int(now.timestamp()),
79
+ "exp": int(exp.timestamp()),
80
+ }
81
+
82
+ if identity.tenant_id is not None:
83
+ payload["tenant_id"] = identity.tenant_id
84
+
85
+ if self._issuer is not None:
86
+ payload["iss"] = self._issuer
87
+
88
+ if self._audience is not None:
89
+ payload["aud"] = self._audience
90
+
91
+ # Merge custom claims without overwriting standard registered claims
92
+ for k, v in identity.claims.items():
93
+ if k not in payload:
94
+ payload[k] = v
95
+
96
+ try:
97
+ return jwt.encode(payload, self._secret_key, algorithm=self._algorithm)
98
+ except Exception as exc:
99
+ raise AuthError(f"JWT signing failed: {exc}") from exc
100
+
101
+ def verify_token(self, token: str) -> Identity:
102
+ """Decode and verify a JWT token, reconstructing the Identity.
103
+
104
+ Args:
105
+ token: The raw JWT string.
106
+
107
+ Returns:
108
+ Verified Identity domain instance.
109
+
110
+ Raises:
111
+ TokenExpiredError: If the token has expired.
112
+ InvalidTokenError: If the signature or structure is invalid.
113
+ AuthError: If general verification fails.
114
+ """
115
+ if not token:
116
+ raise InvalidTokenError("Token string cannot be empty.")
117
+
118
+ options = {"verify_exp": True}
119
+ kwargs: dict[str, Any] = {
120
+ "key": self._secret_key,
121
+ "algorithms": [self._algorithm],
122
+ "options": options,
123
+ }
124
+
125
+ if self._issuer is not None:
126
+ kwargs["issuer"] = self._issuer
127
+ if self._audience is not None:
128
+ kwargs["audience"] = self._audience
129
+
130
+ try:
131
+ claims: dict[str, Any] = jwt.decode(token, **kwargs)
132
+ except jwt.ExpiredSignatureError as exc:
133
+ raise TokenExpiredError(f"JWT token expired: {exc}") from exc
134
+ except jwt.PyJWTError as exc:
135
+ raise InvalidTokenError(f"Invalid JWT token: {exc}") from exc
136
+ except Exception as exc:
137
+ raise AuthError(f"JWT token verification failed: {exc}") from exc
138
+
139
+ sub = claims.get("sub")
140
+ if not sub:
141
+ raise InvalidTokenError("JWT token missing required 'sub' claim.")
142
+
143
+ roles = frozenset(claims.get("roles", []))
144
+ permissions = frozenset(claims.get("permissions", []))
145
+ tenant_id = claims.get("tenant_id")
146
+
147
+ return Identity(
148
+ user_id=str(sub),
149
+ roles=roles,
150
+ permissions=permissions,
151
+ tenant_id=str(tenant_id) if tenant_id is not None else None,
152
+ claims=claims,
153
+ is_authenticated=True,
154
+ )
155
+
156
+
157
+ __all__ = [
158
+ "JwtSecurityAdapter",
159
+ ]
@@ -0,0 +1,5 @@
1
+ from hexastack_auth.adapters.opa.policy import OpaPolicyAdapter
2
+
3
+ __all__ = [
4
+ "OpaPolicyAdapter",
5
+ ]
@@ -0,0 +1,88 @@
1
+ """Open Policy Agent (OPA) policy evaluation adapter.
2
+
3
+ Notes/Architectural Intent:
4
+ Evaluates policy queries against OPA's HTTP Data API (v1/data/{policy_path}).
5
+ Passes user identity, roles, permissions, tenant_id, action, resource, and extra context.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from hexastack_auth.domain.models import Identity
12
+ from hexastack_auth.ports.policy import AuthorizationPolicyPort
13
+ from hexastack_core.domain.exceptions import MissingDependencyError
14
+
15
+ __all__ = [
16
+ "OpaPolicyAdapter",
17
+ ]
18
+
19
+
20
+ class OpaPolicyAdapter(AuthorizationPolicyPort):
21
+ """AuthorizationPolicyPort implementation querying Open Policy Agent (OPA)."""
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = "http://localhost:8181",
26
+ default_policy_path: str = "v1/data/authz/allow",
27
+ timeout: float = 3.0,
28
+ ) -> None:
29
+ self.base_url = base_url.rstrip("/")
30
+ self.default_policy_path = default_policy_path.strip("/")
31
+ self.timeout = timeout
32
+
33
+ def is_authorized(
34
+ self,
35
+ identity: Identity,
36
+ action: str,
37
+ resource: str,
38
+ *,
39
+ context: Mapping[str, Any] | None = None,
40
+ ) -> bool:
41
+ """Evaluate policy rule against OPA REST Data API."""
42
+ try:
43
+ import httpx
44
+ except ImportError as e:
45
+ raise MissingDependencyError(
46
+ "httpx is required for OpaPolicyAdapter. "
47
+ "Install with 'pip install hexastack-auth[opa]'."
48
+ ) from e
49
+
50
+ # Determine target policy path (use action if it starts with 'v1/data/' or 'policies/')
51
+ policy_path = (
52
+ action.strip("/")
53
+ if action.startswith("v1/data/") or action.startswith("policies/")
54
+ else self.default_policy_path
55
+ )
56
+ url = f"{self.base_url}/{policy_path}"
57
+
58
+ input_payload = {
59
+ "input": {
60
+ "identity": {
61
+ "user_id": identity.user_id,
62
+ "tenant_id": identity.tenant_id,
63
+ "roles": list(identity.roles),
64
+ "permissions": list(identity.permissions),
65
+ "claims": dict(identity.claims),
66
+ "is_authenticated": identity.is_authenticated,
67
+ },
68
+ "action": action,
69
+ "resource": resource,
70
+ "context": dict(context or {}),
71
+ }
72
+ }
73
+
74
+ try:
75
+ with httpx.Client(timeout=self.timeout) as client:
76
+ response = client.post(url, json=input_payload)
77
+ if response.status_code != 200:
78
+ return False
79
+
80
+ data = response.json()
81
+ result = data.get("result")
82
+ if isinstance(result, bool):
83
+ return result
84
+ if isinstance(result, dict):
85
+ return bool(result.get("allow", False))
86
+ return bool(result)
87
+ except Exception:
88
+ return False
@@ -0,0 +1,5 @@
1
+ from hexastack_auth.adapters.openfga.policy import OpenFgaPolicyAdapter
2
+
3
+ __all__ = [
4
+ "OpenFgaPolicyAdapter",
5
+ ]