keyshield 2.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.
- keyshield/__init__.py +11 -0
- keyshield/__main__.py +69 -0
- keyshield/_schemas.py +201 -0
- keyshield/_types.py +21 -0
- keyshield/api.py +491 -0
- keyshield/cli.py +420 -0
- keyshield/django/__init__.py +13 -0
- keyshield/django/apps.py +21 -0
- keyshield/django/decorators.py +85 -0
- keyshield/django/models.py +66 -0
- keyshield/django/repository.py +231 -0
- keyshield/django/urls.py +60 -0
- keyshield/django/views.py +312 -0
- keyshield/domain/__init__.py +0 -0
- keyshield/domain/base.py +120 -0
- keyshield/domain/entities.py +190 -0
- keyshield/domain/errors.py +58 -0
- keyshield/hasher/__init__.py +3 -0
- keyshield/hasher/argon2.py +47 -0
- keyshield/hasher/base.py +134 -0
- keyshield/hasher/bcrypt.py +41 -0
- keyshield/litestar_api.py +370 -0
- keyshield/py.typed +0 -0
- keyshield/quart_api.py +378 -0
- keyshield/repositories/__init__.py +0 -0
- keyshield/repositories/base.py +150 -0
- keyshield/repositories/in_memory.py +142 -0
- keyshield/repositories/sql.py +330 -0
- keyshield/services/__init__.py +0 -0
- keyshield/services/base.py +526 -0
- keyshield/services/cached.py +133 -0
- keyshield/utils.py +25 -0
- keyshield-2.0.0.dist-info/METADATA +460 -0
- keyshield-2.0.0.dist-info/RECORD +37 -0
- keyshield-2.0.0.dist-info/WHEEL +4 -0
- keyshield-2.0.0.dist-info/entry_points.txt +2 -0
- keyshield-2.0.0.dist-info/licenses/LICENSE +21 -0
keyshield/__init__.py
ADDED
keyshield/__main__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typer import Typer
|
|
5
|
+
|
|
6
|
+
from keyshield.services.base import DEFAULT_SEPARATOR, DEFAULT_GLOBAL_PREFIX
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
app = Typer(no_args_is_help=True, help="FastAPI API Keys CLI")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def _main(
|
|
14
|
+
version: bool = typer.Option(
|
|
15
|
+
None,
|
|
16
|
+
"--version",
|
|
17
|
+
"-v",
|
|
18
|
+
help="Show the FastAPI API Keys package version and exit.",
|
|
19
|
+
is_eager=True,
|
|
20
|
+
),
|
|
21
|
+
):
|
|
22
|
+
"""FastAPI API Keys CLI"""
|
|
23
|
+
from keyshield import __version__
|
|
24
|
+
|
|
25
|
+
if version:
|
|
26
|
+
typer.echo(__version__)
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def generate(
|
|
32
|
+
global_prefix: str = DEFAULT_GLOBAL_PREFIX,
|
|
33
|
+
key_id: Optional[str] = None,
|
|
34
|
+
key_secret: Optional[str] = None,
|
|
35
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Generate a new API key for set in dotenv file."""
|
|
38
|
+
from keyshield.domain.entities import ApiKey
|
|
39
|
+
from keyshield.utils import key_id_factory, key_secret_factory
|
|
40
|
+
|
|
41
|
+
key_id = key_id or key_id_factory()
|
|
42
|
+
key_secret = key_secret or key_secret_factory()
|
|
43
|
+
|
|
44
|
+
api_key = ApiKey.get_api_key(
|
|
45
|
+
global_prefix=global_prefix,
|
|
46
|
+
key_id=key_id,
|
|
47
|
+
key_secret=key_secret,
|
|
48
|
+
separator=separator,
|
|
49
|
+
)
|
|
50
|
+
typer.echo(f'Set in your .env : "API_KEY_DEV={api_key}"')
|
|
51
|
+
return api_key
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command(name="pepper")
|
|
55
|
+
def generate_pepper() -> str:
|
|
56
|
+
"""Generate a new pepper for hashing API keys."""
|
|
57
|
+
from keyshield.utils import key_secret_factory
|
|
58
|
+
|
|
59
|
+
pepper = key_secret_factory(length=32)
|
|
60
|
+
typer.echo(f'Set in your .env : "SECRET_PEPPER={pepper}"')
|
|
61
|
+
return pepper
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
app()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
app()
|
keyshield/_schemas.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Shared Pydantic schemas used by all framework integrations.
|
|
2
|
+
|
|
3
|
+
These models are framework-agnostic and shared between FastAPI, Litestar
|
|
4
|
+
and Quart integrations to avoid duplication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"Pydantic is required for framework integrations. "
|
|
12
|
+
"Install it via any supported framework extra, e.g.: "
|
|
13
|
+
"uv add keyshield[fastapi] or uv add keyshield[litestar]"
|
|
14
|
+
) from e
|
|
15
|
+
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from typing import Annotated, List, Optional
|
|
18
|
+
|
|
19
|
+
from keyshield.domain.entities import ApiKey
|
|
20
|
+
from keyshield.repositories.base import ApiKeyFilter, SortableColumn
|
|
21
|
+
from keyshield.utils import datetime_factory
|
|
22
|
+
|
|
23
|
+
# Scope strings must follow the format: lowercase letter, then lowercase
|
|
24
|
+
# alphanumerics, colons, underscores, or hyphens (e.g. "read", "api:write").
|
|
25
|
+
_ScopeStr = Annotated[str, Field(pattern=r"^[a-z][a-z0-9:_\-]*$")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiKeyCreateIn(BaseModel):
|
|
29
|
+
"""Payload to create a new API key.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
name: Human-friendly display name.
|
|
33
|
+
description: Optional description to document the purpose of the key.
|
|
34
|
+
is_active: Whether the key is active upon creation.
|
|
35
|
+
scopes: List of scopes to assign to the key.
|
|
36
|
+
expires_at: Optional expiration datetime (ISO 8601 format).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str = Field(..., min_length=1, max_length=128)
|
|
40
|
+
description: Optional[str] = Field(None, max_length=1024)
|
|
41
|
+
is_active: bool = Field(default=True)
|
|
42
|
+
scopes: List[_ScopeStr] = Field(default_factory=list)
|
|
43
|
+
expires_at: Optional[datetime] = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
examples=[(datetime_factory() + timedelta(days=30)).isoformat()],
|
|
46
|
+
description="Expiration datetime (ISO 8601)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ApiKeyUpdateIn(BaseModel):
|
|
51
|
+
"""Partial update payload for an API key.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
name: New display name.
|
|
55
|
+
description: New description.
|
|
56
|
+
is_active: Toggle active state.
|
|
57
|
+
scopes: New list of scopes.
|
|
58
|
+
expires_at: New expiration datetime (ISO 8601 format).
|
|
59
|
+
clear_expires: Set to true to remove expiration (takes precedence over expires_at).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: Optional[str] = Field(None, min_length=1, max_length=128)
|
|
63
|
+
description: Optional[str] = Field(None, max_length=1024)
|
|
64
|
+
is_active: Optional[bool] = None
|
|
65
|
+
scopes: Optional[List[_ScopeStr]] = None
|
|
66
|
+
expires_at: Optional[datetime] = Field(None, description="New expiration datetime (ISO 8601)")
|
|
67
|
+
clear_expires: bool = Field(False, description="Remove expiration date")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ApiKeyOut(BaseModel):
|
|
71
|
+
"""Public representation of an API key entity.
|
|
72
|
+
|
|
73
|
+
Note:
|
|
74
|
+
Timestamps are optional to avoid coupling to a particular repository
|
|
75
|
+
schema. If your entity guarantees those fields, they will be populated.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
id: Unique identifier of the API key.
|
|
79
|
+
key_id: Public key identifier (used for lookup, visible in the API key string).
|
|
80
|
+
name: Human-friendly display name.
|
|
81
|
+
description: Optional description documenting the key's purpose.
|
|
82
|
+
is_active: Whether the key is currently active.
|
|
83
|
+
created_at: When the key was created.
|
|
84
|
+
last_used_at: When the key was last used for authentication.
|
|
85
|
+
expires_at: When the key expires (None means no expiration).
|
|
86
|
+
scopes: List of scopes assigned to this key.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
key_id: str
|
|
91
|
+
name: Optional[str] = None
|
|
92
|
+
description: Optional[str] = None
|
|
93
|
+
is_active: bool
|
|
94
|
+
created_at: Optional[datetime] = None
|
|
95
|
+
last_used_at: Optional[datetime] = None
|
|
96
|
+
expires_at: Optional[datetime] = None
|
|
97
|
+
scopes: List[str] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ApiKeyCreatedOut(BaseModel):
|
|
101
|
+
"""Response returned after creating a key.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
api_key: The plaintext API key value (only returned once!). Store it
|
|
105
|
+
securely client-side; it cannot be retrieved again.
|
|
106
|
+
entity: Public representation of the stored entity.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
api_key: str
|
|
110
|
+
entity: ApiKeyOut
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ApiKeySearchIn(BaseModel):
|
|
114
|
+
"""Search criteria for filtering API keys.
|
|
115
|
+
|
|
116
|
+
All criteria are optional. Only provided criteria are applied (AND logic).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
is_active: Optional[bool] = Field(None, description="Filter by active status")
|
|
120
|
+
expires_before: Optional[datetime] = Field(None, description="Keys expiring before this date")
|
|
121
|
+
expires_after: Optional[datetime] = Field(None, description="Keys expiring after this date")
|
|
122
|
+
created_before: Optional[datetime] = Field(None, description="Keys created before this date")
|
|
123
|
+
created_after: Optional[datetime] = Field(None, description="Keys created after this date")
|
|
124
|
+
last_used_before: Optional[datetime] = Field(None, description="Keys last used before this date")
|
|
125
|
+
last_used_after: Optional[datetime] = Field(None, description="Keys last used after this date")
|
|
126
|
+
never_used: Optional[bool] = Field(None, description="True = never used keys, False = used keys")
|
|
127
|
+
scopes_contain_all: Optional[List[_ScopeStr]] = Field(None, description="Keys must have ALL these scopes")
|
|
128
|
+
scopes_contain_any: Optional[List[_ScopeStr]] = Field(
|
|
129
|
+
None, description="Keys must have at least ONE of these scopes"
|
|
130
|
+
)
|
|
131
|
+
name_contains: Optional[str] = Field(None, description="Name contains this substring (case-insensitive)")
|
|
132
|
+
name_exact: Optional[str] = Field(None, description="Exact name match")
|
|
133
|
+
order_by: SortableColumn = Field(SortableColumn.CREATED_AT, description="Field to sort by")
|
|
134
|
+
order_desc: bool = Field(True, description="Sort descending (True) or ascending (False)")
|
|
135
|
+
|
|
136
|
+
def to_filter(self, limit: int = 100, offset: int = 0) -> ApiKeyFilter:
|
|
137
|
+
"""Convert to ApiKeyFilter with pagination."""
|
|
138
|
+
return ApiKeyFilter(
|
|
139
|
+
is_active=self.is_active,
|
|
140
|
+
expires_before=self.expires_before,
|
|
141
|
+
expires_after=self.expires_after,
|
|
142
|
+
created_before=self.created_before,
|
|
143
|
+
created_after=self.created_after,
|
|
144
|
+
last_used_before=self.last_used_before,
|
|
145
|
+
last_used_after=self.last_used_after,
|
|
146
|
+
never_used=self.never_used,
|
|
147
|
+
scopes_contain_all=self.scopes_contain_all,
|
|
148
|
+
scopes_contain_any=self.scopes_contain_any,
|
|
149
|
+
name_contains=self.name_contains,
|
|
150
|
+
name_exact=self.name_exact,
|
|
151
|
+
order_by=self.order_by,
|
|
152
|
+
order_desc=self.order_desc,
|
|
153
|
+
limit=limit,
|
|
154
|
+
offset=offset,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ApiKeySearchOut(BaseModel):
|
|
159
|
+
"""Paginated search results."""
|
|
160
|
+
|
|
161
|
+
items: List[ApiKeyOut] = Field(description="List of matching API keys")
|
|
162
|
+
total: int = Field(description="Total number of matching keys (ignoring pagination)")
|
|
163
|
+
limit: int = Field(description="Page size used")
|
|
164
|
+
offset: int = Field(description="Offset used")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ApiKeyVerifyIn(BaseModel):
|
|
168
|
+
"""Payload to verify an API key.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
api_key: The full API key string to verify.
|
|
172
|
+
required_scopes: Optional list of scopes the key must have.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
api_key: str = Field(..., min_length=1, description="Full API key string to verify")
|
|
176
|
+
required_scopes: Optional[List[str]] = Field(None, description="Scopes the key must have")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ApiKeyCountOut(BaseModel):
|
|
180
|
+
"""Response for counting API keys.
|
|
181
|
+
|
|
182
|
+
Attributes:
|
|
183
|
+
total: Total number of keys matching the filter criteria.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
total: int = Field(description="Total number of matching keys")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _to_out(entity: ApiKey) -> ApiKeyOut:
|
|
190
|
+
"""Map an ``ApiKey`` entity to the public ``ApiKeyOut`` schema."""
|
|
191
|
+
return ApiKeyOut(
|
|
192
|
+
id=entity.id_,
|
|
193
|
+
key_id=entity.key_id,
|
|
194
|
+
name=entity.name,
|
|
195
|
+
description=entity.description,
|
|
196
|
+
is_active=entity.is_active,
|
|
197
|
+
created_at=entity.created_at,
|
|
198
|
+
last_used_at=entity.last_used_at,
|
|
199
|
+
expires_at=entity.expires_at,
|
|
200
|
+
scopes=entity.scopes,
|
|
201
|
+
)
|
keyshield/_types.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from contextlib import AbstractAsyncContextManager
|
|
2
|
+
from typing import Callable, Awaitable
|
|
3
|
+
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials
|
|
5
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
|
6
|
+
|
|
7
|
+
from keyshield.domain.entities import ApiKey
|
|
8
|
+
from keyshield.services.base import AbstractApiKeyService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
AsyncSessionMaker = async_sessionmaker[AsyncSession]
|
|
12
|
+
"""Type alias for an "async_sessionmaker" instance of SQLAlchemy."""
|
|
13
|
+
|
|
14
|
+
SecurityHTTPBearer = Callable[[HTTPAuthorizationCredentials], Awaitable[ApiKey]]
|
|
15
|
+
"""Type alias for a security dependency callable using HTTP Bearer scheme."""
|
|
16
|
+
|
|
17
|
+
SecurityAPIKeyHeader = Callable[[str], Awaitable[ApiKey]]
|
|
18
|
+
"""Type alias for a security dependency callable using API Key Header scheme."""
|
|
19
|
+
|
|
20
|
+
ServiceFactory = Callable[[], AbstractAsyncContextManager[AbstractApiKeyService]]
|
|
21
|
+
"""Callable returning an async context manager that yields an API key service instance."""
|